mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Merge branch 'dev' into freight/nati-2
# Conflicts: # apps/edr-freight-api/src/app.module.ts # apps/edr-freight-api/src/seed/freight-permissions.registry.ts # apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx # apps/edr-freight-web/backoffice/src/constants/URLS.ts # apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingClearanceCharge,
|
||||
ClearanceChargeType,
|
||||
} from './entities/booking-clearance-charge.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
|
||||
/** File-record codes the charge documents are stored under on the booking. */
|
||||
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: 'clearance_charge_port',
|
||||
MISCELLANEOUS: 'clearance_charge_misc',
|
||||
};
|
||||
|
||||
const CHARGE_LABEL: Record<ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: 'Port charges',
|
||||
MISCELLANEOUS: 'Miscellaneous charges',
|
||||
};
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charges billed to the customer. Two levels per
|
||||
* booking: GL Djibouti uploads the port-charges document; GL Ethiopia bills it
|
||||
* (amount + currency) and sends the invoice; once that invoice is paid GL
|
||||
* Ethiopia may create and send the miscellaneous charge. ETB invoices are paid
|
||||
* through the portal gateway, other currencies through Finance's manual
|
||||
* settlement worklist — both settle via `clearance_charge.invoice.paid`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingClearanceChargeService {
|
||||
private readonly logger = new Logger(BookingClearanceChargeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
) {}
|
||||
|
||||
private repo() {
|
||||
return this.dataSource.getRepository(BookingClearanceCharge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges are a post-finalization step: block while the customer's clearance
|
||||
* documents are still being collected/reviewed.
|
||||
*/
|
||||
private assertClearanceFinalized(booking: Booking): void {
|
||||
const inReview =
|
||||
booking.status === 'AWAITING_DOCUMENTS' ||
|
||||
booking.status === 'DOCUMENTS_UNDER_REVIEW';
|
||||
if (inReview && !booking.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'Clearance charges open after document clearance is finalized.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async list(bookingId: string): Promise<Freight.ClearanceCharge[]> {
|
||||
const charges = await this.repo().find({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (charges.length === 0) return [];
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const fileById = new Map(files.map((f) => [f.id, f]));
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
charges.flatMap((c) => [c.uploadedByStaffId, c.billedByStaffId]),
|
||||
);
|
||||
const invoiceIds = charges
|
||||
.map((c) => c.invoiceId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const invoices = invoiceIds.length
|
||||
? await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.find({ where: invoiceIds.map((id) => ({ id })) })
|
||||
: [];
|
||||
const invoiceById = new Map(invoices.map((i) => [i.id, i]));
|
||||
|
||||
return charges.map((c) => {
|
||||
const file = c.fileRecordId ? (fileById.get(c.fileRecordId) ?? null) : null;
|
||||
return {
|
||||
id: c.id,
|
||||
bookingId: c.bookingId,
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
amount: c.amount != null ? Number(c.amount) : null,
|
||||
currency: c.currency ?? null,
|
||||
invoiceId: c.invoiceId ?? null,
|
||||
invoiceNumber: c.invoiceId
|
||||
? (invoiceById.get(c.invoiceId)?.invoiceNumber ?? null)
|
||||
: null,
|
||||
uploadedByName: c.uploadedByStaffId
|
||||
? (names.get(c.uploadedByStaffId) ?? null)
|
||||
: null,
|
||||
uploadedAt: c.uploadedAt ? c.uploadedAt.toISOString() : null,
|
||||
billedByName: c.billedByStaffId
|
||||
? (names.get(c.billedByStaffId) ?? null)
|
||||
: null,
|
||||
billedAt: c.billedAt ? c.billedAt.toISOString() : null,
|
||||
paidAt: c.paidAt ? c.paidAt.toISOString() : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
|
||||
async uploadPortDocument(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.assertClearanceFinalized(booking);
|
||||
|
||||
const existing = await this.repo().findOne({
|
||||
where: { bookingId, type: 'PORT_CHARGES' },
|
||||
});
|
||||
if (existing && existing.status !== 'DOC_UPLOADED') {
|
||||
throw new ConflictException(
|
||||
'The port charge has already been billed — ask GL Ethiopia to revise it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: CHARGE_FILE_CODE.PORT_CHARGES,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await this.repo().update(existing.id, {
|
||||
fileRecordId: record.id,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
await this.repo().save(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'PORT_CHARGES',
|
||||
status: 'DOC_UPLOADED',
|
||||
fileRecordId: record.id,
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_PORT_DOC_UPLOADED',
|
||||
label: existing
|
||||
? 'Replaced the port-charges document'
|
||||
: 'Uploaded the port-charges document',
|
||||
actorId: staffId,
|
||||
metadata: { fileName: file.originalname },
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sets (or, on the customer's request, revises) amount +
|
||||
* currency. Revising a SENT charge cancels its unpaid invoice; a PAID charge
|
||||
* is immutable.
|
||||
*/
|
||||
async billCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
input: { amount: number; currency: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status === 'PAID') {
|
||||
throw new ConflictException('A paid charge can no longer be changed.');
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
if (charge.status === 'SENT' && charge.invoiceId) {
|
||||
await this.billing.cancelInvoice(charge.invoiceId);
|
||||
}
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
status: 'BILLED',
|
||||
invoiceId: null,
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_BILLED',
|
||||
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
|
||||
charge.type
|
||||
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
revised: charge.status === 'SENT',
|
||||
},
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** GL Ethiopia issues the payable invoice to the customer. */
|
||||
async sendCharge(
|
||||
bookingId: string,
|
||||
chargeId: string,
|
||||
staffId?: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: chargeId, bookingId },
|
||||
});
|
||||
if (!charge) throw new NotFoundException('Clearance charge not found');
|
||||
if (charge.status !== 'BILLED') {
|
||||
throw new ConflictException(
|
||||
'Set the amount and currency before sending the charge to the customer.',
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.ClearanceCharge,
|
||||
// The charge's own id, NOT the booking id — booking-scoped invoice
|
||||
// lookups (findPayable/expirePayable/CBE billQuery) must never match it.
|
||||
sourceId: charge.id,
|
||||
type: charge.type,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: charge.currency ?? 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: charge.type,
|
||||
description: `${CHARGE_LABEL[charge.type]} — ${booking.reference ?? bookingId}`,
|
||||
amount: Number(charge.amount),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'SENT',
|
||||
invoiceId: invoice.id,
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_INVOICE_SENT',
|
||||
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
|
||||
actorId: staffId ?? null,
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amount: Number(charge.amount),
|
||||
currency: charge.currency,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia creates the miscellaneous charge whole (document + amount +
|
||||
* currency). Second payment level: allowed only once the port charge is paid.
|
||||
*/
|
||||
async createMiscellaneous(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
input: { amount: number; currency: string },
|
||||
staffId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
this.assertClearanceFinalized(booking);
|
||||
|
||||
const port = await this.repo().findOne({
|
||||
where: { bookingId, type: 'PORT_CHARGES' },
|
||||
});
|
||||
if (port?.status !== 'PAID') {
|
||||
throw new ConflictException(
|
||||
'Miscellaneous charges open after the port charge is paid.',
|
||||
);
|
||||
}
|
||||
const existing = await this.repo().findOne({
|
||||
where: { bookingId, type: 'MISCELLANEOUS' },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'This booking already has a miscellaneous charge — revise it instead.',
|
||||
);
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Amount must be greater than zero.');
|
||||
}
|
||||
if (!input.currency?.trim()) {
|
||||
throw new BadRequestException('Currency is required.');
|
||||
}
|
||||
|
||||
const record = await this.filesService.upsertByCode(
|
||||
{
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: CHARGE_FILE_CODE.MISCELLANEOUS,
|
||||
file,
|
||||
},
|
||||
{ userId: staffId },
|
||||
);
|
||||
await this.repo().save(
|
||||
this.repo().create({
|
||||
bookingId,
|
||||
type: 'MISCELLANEOUS',
|
||||
status: 'BILLED',
|
||||
fileRecordId: record.id,
|
||||
amount: input.amount.toFixed(2),
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
uploadedByStaffId: staffId,
|
||||
uploadedAt: new Date(),
|
||||
billedByStaffId: staffId,
|
||||
billedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CHARGE_MISC_CREATED',
|
||||
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
|
||||
actorId: staffId,
|
||||
metadata: {
|
||||
amount: input.amount,
|
||||
currency: input.currency.trim().toUpperCase(),
|
||||
fileName: file.originalname,
|
||||
},
|
||||
});
|
||||
return this.list(bookingId);
|
||||
}
|
||||
|
||||
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
|
||||
@OnEvent('clearance_charge.invoice.paid')
|
||||
async onChargeInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
const charge = await this.repo().findOne({
|
||||
where: { id: payload.sourceId },
|
||||
});
|
||||
if (!charge || charge.status === 'PAID') return;
|
||||
await this.repo().update(charge.id, {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
});
|
||||
await this.clearanceEvents.record({
|
||||
bookingId: charge.bookingId,
|
||||
action: 'CHARGE_PAID',
|
||||
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
|
||||
actorType: 'SYSTEM',
|
||||
metadata: {
|
||||
chargeType: charge.type,
|
||||
invoiceNumber: payload.invoiceNumber,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,36 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
|
||||
* on one wagon is a commercial call, so this never auto-advances.
|
||||
*/
|
||||
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon needs approval',
|
||||
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was approved; both halves move on to Operations together. */
|
||||
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon approved',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was rejected; both halves go back to GL for changes. */
|
||||
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon rejected',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — review is next. */
|
||||
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
notifier as never,
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
|
||||
* together: accepting one alone would put half a wagon into the approval chain,
|
||||
* and cancelling one alone would strand the other on a wagon it can no longer
|
||||
* fill. All-or-nothing — if either half throws, neither booking moved.
|
||||
*/
|
||||
describe('BookingTransitionService — paired staff decisions', () => {
|
||||
function makeService(booking: Partial<Booking>) {
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking as Booking),
|
||||
};
|
||||
// Runs the callback so a throw propagates, which is what the all-or-nothing
|
||||
// guarantee reduces to from this service's point of view.
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
{} as never, // bookingsRepository
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{} as never, // bookingClearanceService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // containerValidationService
|
||||
{} as never, // notifier
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
{} as never, // events
|
||||
undefined, // milestoneService
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, dataSource };
|
||||
}
|
||||
|
||||
const paired = {
|
||||
id: 'b-1',
|
||||
reference: 'BK-1',
|
||||
consolidationPartnerId: 'b-2',
|
||||
} as Booking;
|
||||
|
||||
it('accepts both halves with the same validity window', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const accept = jest
|
||||
.spyOn(service, 'acceptIntake')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
|
||||
validityDays: 30,
|
||||
});
|
||||
|
||||
expect(accept).toHaveBeenCalledTimes(2);
|
||||
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
|
||||
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
|
||||
expect(result.booking.id).toBe('b-1');
|
||||
expect(result.partner.id).toBe('b-2');
|
||||
});
|
||||
|
||||
it('cancels both halves with the same reason', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const cancel = jest
|
||||
.spyOn(service, 'cancel')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
|
||||
reason: 'customer withdrew',
|
||||
});
|
||||
|
||||
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
|
||||
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
|
||||
});
|
||||
|
||||
it('propagates a failure on the second half so neither is committed', async () => {
|
||||
const { service, dataSource } = makeService(paired);
|
||||
jest
|
||||
.spyOn(service, 'cancel')
|
||||
.mockImplementationOnce(async (id) => ({ id }) as Booking)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('partner is already in transit');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||
).rejects.toThrow('partner is already in transit');
|
||||
|
||||
// Both halves ran inside one transaction, so the throw rolls the first back.
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses a booking that has no partner', async () => {
|
||||
const { service } = makeService({
|
||||
id: 'b-1',
|
||||
consolidationPartnerId: null,
|
||||
} as Booking);
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||
).rejects.toThrow(/no consolidation partner/i);
|
||||
});
|
||||
|
||||
it('requires a validity window to accept', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const accept = jest.spyOn(service, 'acceptIntake');
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
|
||||
).rejects.toThrow(/validity/i);
|
||||
expect(accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes operationAccept through the operation review on both halves', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const review = jest
|
||||
.spyOn(service, 'reviewOperationRequest')
|
||||
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||
|
||||
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
|
||||
|
||||
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
|
||||
note: undefined,
|
||||
});
|
||||
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,15 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import {
|
||||
clearanceCodesForBooking,
|
||||
clearanceDocumentsOpen,
|
||||
} from './clearance.util';
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
type ClearanceDocEvent,
|
||||
} from './clearance-doc-history.util';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -68,6 +76,7 @@ export class BookingTransitionService {
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||
@@ -81,6 +90,28 @@ export class BookingTransitionService {
|
||||
|
||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
|
||||
// that cannot be placed. Consolidation (pairing it with another customer's
|
||||
// odd booking) is built end to end but switched off for now, so an odd total
|
||||
// is rejected here rather than parked for a partner.
|
||||
// containerSize is not always populated (some rows carry only the container
|
||||
// type), so fall back to the type's sizeFt rather than silently skipping
|
||||
// those lines and letting an odd booking through.
|
||||
const ft20Quantity = (booking.bookingContainers ?? [])
|
||||
.filter((bc) =>
|
||||
bc.containerSize
|
||||
? bc.containerSize.includes("20")
|
||||
: Number(bc.containerType?.sizeFt) === 20,
|
||||
)
|
||||
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
|
||||
if (ft20Quantity % 2 === 1) {
|
||||
throw new BadRequestException(
|
||||
`20ft containers travel two per wagon, so they must be booked in even ` +
|
||||
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
|
||||
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const violations =
|
||||
await this.containerValidationService.validate20ftPairing(booking);
|
||||
if (violations.length) {
|
||||
@@ -455,6 +486,75 @@ export class BookingTransitionService {
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a staff decision across BOTH halves of a consolidated pair.
|
||||
*
|
||||
* Two bookings that share a wagon must move together: accepting one while the
|
||||
* other stays behind would put half a wagon into the approval chain, and
|
||||
* cancelling one alone would strand the other on a wagon it can no longer
|
||||
* fill. All-or-nothing — if either half throws, the transaction rolls back and
|
||||
* neither booking moved.
|
||||
*
|
||||
* Each half still runs the ordinary single-booking transition, so pricing,
|
||||
* invoicing and notifications stay per booking: the customers are billed and
|
||||
* notified separately, exactly as they are today.
|
||||
*/
|
||||
async applyPairedDecision(
|
||||
bookingId: string,
|
||||
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
||||
actorId: string,
|
||||
options: { reason?: string; note?: string; validityDays?: number } = {},
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
if (!partnerId) {
|
||||
throw new BadRequestException(
|
||||
"This booking has no consolidation partner — use the single-booking action.",
|
||||
);
|
||||
}
|
||||
|
||||
const runOne = async (id: string): Promise<Booking> => {
|
||||
switch (decision) {
|
||||
case "accept":
|
||||
// Same requirement as the single-booking accept: the approval chain
|
||||
// needs a contract validity window.
|
||||
if (!(Number(options.validityDays) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"Contract validity (days) is required to accept.",
|
||||
);
|
||||
}
|
||||
return this.acceptIntake(id, actorId, Number(options.validityDays));
|
||||
case "cancel":
|
||||
return this.cancel(
|
||||
id,
|
||||
options.reason ?? "Cancelled with its consolidation partner",
|
||||
);
|
||||
case "operationAccept":
|
||||
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
|
||||
note: options.note,
|
||||
});
|
||||
case "requestChanges":
|
||||
return this.requestChanges(id, options.note ?? "", actorId);
|
||||
}
|
||||
};
|
||||
|
||||
// Without a DataSource (unit tests hand-construct this service) fall back to
|
||||
// running the two halves directly — the ordering guarantee still holds, only
|
||||
// the rollback does not.
|
||||
if (!this.dataSource) {
|
||||
const own = await runOne(bookingId);
|
||||
const other = await runOne(partnerId);
|
||||
return { booking: own, partner: other };
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async () => {
|
||||
// Sequential: one connection per transaction context.
|
||||
const own = await runOne(bookingId);
|
||||
const other = await runOne(partnerId);
|
||||
return { booking: own, partner: other };
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -536,8 +636,13 @@ export class BookingTransitionService {
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||
note: string | null;
|
||||
uploadedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewedByName: string | null;
|
||||
history: ClearanceDocEvent[];
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
documentsOpen: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
@@ -561,6 +666,18 @@ export class BookingTransitionService {
|
||||
const reviewByKey = new Map(
|
||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||
);
|
||||
const allVersions = await this.filesService.findAllVersionsByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
||||
...reviews.map((r) => r.reviewedByStaffId),
|
||||
...queryNotes.map((n) => n.authorId),
|
||||
]);
|
||||
|
||||
const documents: Awaited<
|
||||
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||
@@ -589,6 +706,18 @@ export class BookingTransitionService {
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
|
||||
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: field.fileKey,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -609,6 +738,18 @@ export class BookingTransitionService {
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
|
||||
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
||||
reviewedByName: review?.reviewedByStaffId
|
||||
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
history: buildClearanceDocHistory({
|
||||
fileKey: f.code,
|
||||
allVersions,
|
||||
queryNotes,
|
||||
review,
|
||||
names: reviewerNames,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -621,6 +762,7 @@ export class BookingTransitionService {
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
documentsOpen: clearanceDocumentsOpen(booking),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -660,12 +802,17 @@ export class BookingTransitionService {
|
||||
async submitClearanceDocuments(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
]);
|
||||
// Documents stay open until the shipment is paid — a customs shipment keeps
|
||||
// collecting paperwork (amended invoices, port documents) well past
|
||||
// clearance finalization. See {@link clearanceDocumentsOpen}.
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException(
|
||||
@@ -703,21 +850,41 @@ export class BookingTransitionService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
// Only the pre-finalization submission drives the booking into review.
|
||||
// A later addition (an amended invoice while the shipment is already
|
||||
// scheduled) must never rewind the status or reopen the phased workflow —
|
||||
// it lands as a new PENDING document for GL to approve where it stands.
|
||||
const inDocumentPhase =
|
||||
booking.status === "AWAITING_DOCUMENTS" ||
|
||||
booking.status === "DOCUMENTS_UNDER_REVIEW";
|
||||
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
if (inDocumentPhase) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
const fileKeys = files.map((f) => f.fieldname);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'DOCS_SUBMITTED',
|
||||
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: userId ?? null,
|
||||
metadata: { fileKeys },
|
||||
});
|
||||
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
||||
return fresh;
|
||||
@@ -770,7 +937,14 @@ export class BookingTransitionService {
|
||||
note?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
// GL keeps reviewing for as long as the customer can still submit — the
|
||||
// two sides share one predicate so they can never drift apart. Documents
|
||||
// added after clearance was finalized still need approving/querying.
|
||||
if (!clearanceDocumentsOpen(booking)) {
|
||||
throw new ConflictException(
|
||||
`Clearance documents are closed for this booking (status "${booking.status}").`,
|
||||
);
|
||||
}
|
||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||
|
||||
const existing =
|
||||
@@ -787,15 +961,6 @@ export class BookingTransitionService {
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
bookingId,
|
||||
@@ -805,6 +970,16 @@ export class BookingTransitionService {
|
||||
staffId,
|
||||
note,
|
||||
);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
|
||||
label:
|
||||
status === 'APPROVED'
|
||||
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
|
||||
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
|
||||
actorId: staffId,
|
||||
metadata: { fileKey, note: note ?? null },
|
||||
});
|
||||
if (status === "QUERIED") {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
@@ -812,7 +987,10 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
// Reopening the review phase only makes sense while clearance is still
|
||||
// being decided. Querying a document that arrived afterwards must not
|
||||
// drag a finalized shipment back into the GL review phase.
|
||||
if (this.isPhasedCustoms(booking) && !booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
@@ -824,7 +1002,10 @@ export class BookingTransitionService {
|
||||
if (status === "QUERIED") {
|
||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||
}
|
||||
if (this.isPhasedCustoms(updated)) {
|
||||
// Same reasoning as the query branch: advance the workflow only while
|
||||
// clearance is still open. Approving a late-added document leaves an
|
||||
// already-finalized shipment's phase exactly where it is.
|
||||
if (this.isPhasedCustoms(updated) && !updated.preClearanceFinalizedAt) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
@@ -845,6 +1026,7 @@ export class BookingTransitionService {
|
||||
async uploadClearanceOutputDocuments(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
@@ -865,6 +1047,15 @@ export class BookingTransitionService {
|
||||
file,
|
||||
});
|
||||
}
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'OUTPUT_DOCS_UPLOADED',
|
||||
label: `Uploaded customs output document(s): ${files
|
||||
.map((f) => f.fieldname.replace(/_/g, ' '))
|
||||
.join(', ')}`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { fileKeys: files.map((f) => f.fieldname) },
|
||||
});
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -872,7 +1063,7 @@ export class BookingTransitionService {
|
||||
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
||||
* and, for customs, the required output documents present → CLEARANCE_READY.
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
@@ -928,6 +1119,12 @@ export class BookingTransitionService {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "CLEARANCE_READY",
|
||||
} as never);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'CLEARANCE_FINALIZED',
|
||||
label: 'Finalized document approval — clearance ready',
|
||||
actorId: userId ?? null,
|
||||
});
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.clearanceReady(fresh);
|
||||
return fresh;
|
||||
@@ -954,6 +1151,8 @@ export class BookingTransitionService {
|
||||
* the customer pools, so the gate here would wrongly reject them).
|
||||
*/
|
||||
bypassDayPool?: boolean;
|
||||
/** Acting user, recorded in the clearance history. */
|
||||
userId?: string;
|
||||
},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
@@ -1068,6 +1267,14 @@ export class BookingTransitionService {
|
||||
scheduledDate: date,
|
||||
requestedTrainScheduleId: requestedId,
|
||||
} as never);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'OPERATION_REQUESTED',
|
||||
label: `Requested operation for shipment day ${scheduledDate}`,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: opts?.userId ?? null,
|
||||
metadata: { scheduledDate },
|
||||
});
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.operationRequestedToStaff(fresh);
|
||||
return fresh;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -38,6 +39,9 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
@@ -50,6 +54,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
@@ -58,7 +63,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveConsolidationDto,
|
||||
CancelBookingDto,
|
||||
PairedDecisionDto,
|
||||
RejectConsolidationDto,
|
||||
RejectBookingDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
@@ -165,6 +173,9 @@ export class BookingsController {
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||
private readonly clearanceEventService: ClearanceEventService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -935,8 +946,8 @@ export class BookingsController {
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
|
||||
return this.bookingClearanceService.etQueue(user);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@@ -966,10 +977,12 @@ export class BookingsController {
|
||||
async submitClearanceDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.submitClearanceDocuments(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
@@ -986,11 +999,13 @@ export class BookingsController {
|
||||
async proceedToOperation(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestOperationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.requestOperation(
|
||||
id,
|
||||
dto.scheduledDate,
|
||||
dto.trainScheduleId ?? null,
|
||||
{ userId: resolveAuthUserId(user) },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
@@ -1068,6 +1083,114 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||
|
||||
@Get(":id/clearance/charges")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary: "Clearance charges billed to the customer (port + miscellaneous)",
|
||||
})
|
||||
getClearanceCharges(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.clearanceChargeService.list(id);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/port-document")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document",
|
||||
})
|
||||
uploadPortChargeDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException("A document file is required");
|
||||
return this.clearanceChargeService.uploadPortDocument(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(":id/clearance/charges/:chargeId/bill")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia sets or revises the charge's amount + currency (revising a sent charge cancels its unpaid invoice)",
|
||||
})
|
||||
billClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@Body() dto: BillClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.billCharge(
|
||||
id,
|
||||
chargeId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/:chargeId/send")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia issues the charge's payable invoice to the customer (ETB pays via gateway, other currencies via manual settlement)",
|
||||
})
|
||||
sendClearanceCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("chargeId", ParseUUIDPipe) chargeId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceChargeService.sendCharge(
|
||||
id,
|
||||
chargeId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/charges/miscellaneous")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"GL Ethiopia creates the miscellaneous charge (document + amount + currency); unlocked once the port charge is paid",
|
||||
})
|
||||
createMiscellaneousCharge(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: BillClearanceChargeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException("A document file is required");
|
||||
return this.clearanceChargeService.createMiscellaneous(
|
||||
id,
|
||||
file,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -1076,10 +1199,12 @@ export class BookingsController {
|
||||
async uploadClearanceOutput(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
@@ -1090,8 +1215,14 @@ export class BookingsController {
|
||||
summary:
|
||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||
})
|
||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.finalizeClearance(id);
|
||||
async finalizeClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.finalizeClearance(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1104,8 +1235,13 @@ export class BookingsController {
|
||||
async requestBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
|
||||
const booking = await this.bookingClearanceService.requestTransitAssignee(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1118,8 +1254,13 @@ export class BookingsController {
|
||||
async assignBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(
|
||||
id,
|
||||
transitAgentId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1203,8 +1344,14 @@ export class BookingsController {
|
||||
summary:
|
||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||
})
|
||||
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
||||
async acceptBookingDraftDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1230,8 +1377,14 @@ export class BookingsController {
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
async finalizeBookingPreClearance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1243,8 +1396,13 @@ export class BookingsController {
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1541,6 +1699,92 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
|
||||
// A consolidated pair is held here, not in the operations queue: two
|
||||
// customers' cargo on one wagon is a commercial call, so a person signs off
|
||||
// on the pairing before Operations sees either half.
|
||||
|
||||
@Get("consolidation-approvals/queue")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
|
||||
})
|
||||
consolidationApprovalQueue() {
|
||||
return this.consolidationApprovalService.queue();
|
||||
}
|
||||
|
||||
@Get(":id/consolidation-approvals")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approval history for this booking's shared wagon — who decided what, when, and why.",
|
||||
})
|
||||
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.consolidationApprovalService.historyForBooking(id);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
|
||||
})
|
||||
approveConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: ApproveConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.approve(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
|
||||
})
|
||||
rejectConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: RejectConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.reject(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/paired-decision")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
|
||||
})
|
||||
async pairedDecision(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: PairedDecisionDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const { booking, partner } = await this.transitionService.applyPairedDecision(
|
||||
id,
|
||||
dto.decision,
|
||||
resolveAuthUserId(user),
|
||||
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
|
||||
);
|
||||
// Sequential enrichment: both go back so the UI can refresh either tab.
|
||||
const enrichedBooking =
|
||||
await this.transitionService.enrichBookingResponse(booking);
|
||||
const enrichedPartner =
|
||||
await this.transitionService.enrichBookingResponse(partner);
|
||||
return { booking: enrichedBooking, partner: enrichedPartner };
|
||||
}
|
||||
|
||||
@Post(":id/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
|
||||
@@ -30,10 +30,17 @@ import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
|
||||
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
|
||||
import { ClearanceEventService } from './clearance-event.service';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
@@ -72,6 +79,9 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
ConsolidationApproval,
|
||||
BookingClearanceCharge,
|
||||
BookingClearanceEvent,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -98,6 +108,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
ContainerValidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
@@ -105,6 +117,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingClearanceChargeService,
|
||||
ClearanceEventService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
@@ -120,12 +134,15 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
exports: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ClearanceEventService,
|
||||
BookingPricingService,
|
||||
ContainerValidationService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
@@ -310,6 +311,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||
* consolidation partner (Path B customs flow). Unlike
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||
* the filter is deliberately looser: any other customs booking on the same
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
*
|
||||
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||
* odd-count test runs on the requested container lines when they exist and the
|
||||
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||
* the split form).
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
// Never offer a booking that already shares a wagon with someone else.
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Customs-only: this manual flow exists because a customs (Path B)
|
||||
// instance is completed by GL, not by the customer.
|
||||
.andWhere('b.customsClearingEnabled = true')
|
||||
// Same physical wagon ⇒ same route and same direction.
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
// Bookable = clearance finished and the booking is waiting to be completed,
|
||||
// the same set completeUnderContract accepts, plus one already parked for a
|
||||
// partner.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: [
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
],
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||
// them on the split form) and stays a candidate; one that already carries
|
||||
// cargo qualifies only when its 20ft total is odd.
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -510,6 +577,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two bookings as consolidation partners WITHOUT touching their statuses.
|
||||
* Used by the manual GL pairing, where both bookings have just been completed
|
||||
* into their live status — unlike {@link pairConsolidation}, which exists to
|
||||
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
|
||||
* of that resume.
|
||||
*/
|
||||
async linkConsolidationPartners(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Un-pair a consolidation. */
|
||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
@@ -536,6 +622,29 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings (of those given) that have at least one customer document still
|
||||
* waiting on GL — PENDING or QUERIED. Includes ad-hoc `custom_*` documents,
|
||||
* which no milestone tracks, so a file added after clearance was finalized
|
||||
* still surfaces as needing review. One query for a whole queue page.
|
||||
*/
|
||||
async findBookingsWithUnreviewedDocuments(
|
||||
bookingIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
if (bookingIds.length === 0) return new Set();
|
||||
const rows = (await this.dataSource
|
||||
.getRepository(BookingDocumentReview)
|
||||
.createQueryBuilder('r')
|
||||
.select('DISTINCT r.booking_id', 'bookingId')
|
||||
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
|
||||
.andWhere('r.status IN (:...statuses)', {
|
||||
statuses: ['PENDING', 'QUERIED'],
|
||||
})
|
||||
.andWhere('r.deleted_at IS NULL')
|
||||
.getRawMany()) as Array<{ bookingId: string }>;
|
||||
return new Set(rows.map((r) => r.bookingId));
|
||||
}
|
||||
|
||||
findDocumentReview(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
@@ -577,6 +686,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await repo.save(repo.create({ ...input, status: 'PENDING' }));
|
||||
}
|
||||
|
||||
/** Display names for reviewer staff ids — one query for the whole set. */
|
||||
async resolveStaffNames(
|
||||
staffIds: (string | null | undefined)[],
|
||||
): Promise<Map<string, string>> {
|
||||
return resolveIamUserNames(this.dataSource, staffIds);
|
||||
}
|
||||
|
||||
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||
async setDocumentReviewStatus(
|
||||
bookingId: string,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import type { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import type { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** One entry of a clearance document's per-card audit trail, oldest first. */
|
||||
export interface ClearanceDocEvent {
|
||||
type: 'UPLOADED' | 'RESUBMITTED' | 'QUERIED' | 'APPROVED';
|
||||
at: string;
|
||||
byName: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query review notes are written as `Document "<fileKey>" queried: <note>`
|
||||
* (see BookingTransitionService.reviewDocument) — the only place a past query
|
||||
* decision survives after the customer re-uploads and the review row resets.
|
||||
*/
|
||||
const QUERY_NOTE_RE = /^Document "(.+?)" queried: ([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* Per-document audit trail assembled from data the flow already persists:
|
||||
* every stored file version (first = customer upload, later ones = the
|
||||
* customer's amendment responses), every query note (who opened it, when,
|
||||
* why), and the review row's current approval. Approvals that were later
|
||||
* reset by a re-upload are the one thing not kept anywhere — the trail shows
|
||||
* the decision that currently stands.
|
||||
*/
|
||||
export function buildClearanceDocHistory(input: {
|
||||
fileKey: string;
|
||||
/** All versions of all files on the booking, createdAt ASC, deleted included. */
|
||||
allVersions: FileRecord[];
|
||||
/** CHANGES_REQUESTED review notes for the booking. */
|
||||
queryNotes: BookingReviewNote[];
|
||||
review: BookingDocumentReview | null;
|
||||
/** staff id → display name. */
|
||||
names: Map<string, string>;
|
||||
}): ClearanceDocEvent[] {
|
||||
const { fileKey, allVersions, queryNotes, review, names } = input;
|
||||
const events: ClearanceDocEvent[] = [];
|
||||
|
||||
const versions = allVersions.filter((v) => v.code === fileKey);
|
||||
versions.forEach((v, i) => {
|
||||
events.push({
|
||||
type: i === 0 ? 'UPLOADED' : 'RESUBMITTED',
|
||||
at: v.createdAt.toISOString(),
|
||||
byName: v.uploadedByName ?? null,
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
for (const n of queryNotes) {
|
||||
const m = QUERY_NOTE_RE.exec(n.note);
|
||||
if (!m || m[1] !== fileKey) continue;
|
||||
events.push({
|
||||
type: 'QUERIED',
|
||||
at: n.createdAt.toISOString(),
|
||||
byName: n.authorId ? (names.get(n.authorId) ?? null) : null,
|
||||
note: m[2] || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (review?.status === 'APPROVED' && review.reviewedAt) {
|
||||
events.push({
|
||||
type: 'APPROVED',
|
||||
at: review.reviewedAt.toISOString(),
|
||||
byName: review.reviewedByStaffId
|
||||
? (names.get(review.reviewedByStaffId) ?? null)
|
||||
: null,
|
||||
note: null,
|
||||
});
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { clearanceDocumentsOpen } from './clearance.util';
|
||||
|
||||
/**
|
||||
* The customer may attach clearance documents — and GL may review them — right
|
||||
* up to payment, not merely until clearance is finalized. Both the upload and
|
||||
* the review endpoint gate on this one predicate, so a drift here silently
|
||||
* desynchronizes the two sides.
|
||||
*/
|
||||
const booking = (patch: Partial<Booking>): Booking =>
|
||||
({ status: 'CLEARANCE_READY', paymentStatus: 'PENDING', ...patch }) as Booking;
|
||||
|
||||
describe('clearanceDocumentsOpen', () => {
|
||||
it('stays open across the whole pre-payment flow', () => {
|
||||
for (const status of [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
]) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes once the shipment is paid or finished', () => {
|
||||
for (const status of ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED']) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes on a dead booking', () => {
|
||||
for (const status of ['REJECTED', 'CANCELLED', 'EXPIRED']) {
|
||||
expect(clearanceDocumentsOpen(booking({ status }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when payment settled before the status caught up', () => {
|
||||
expect(
|
||||
clearanceDocumentsOpen(
|
||||
booking({ status: 'PNR_GENERATED', paymentStatus: 'PAID' }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||
import {
|
||||
BookingClearanceEvent,
|
||||
ClearanceEventActorType,
|
||||
} from './entities/booking-clearance-event.entity';
|
||||
|
||||
export interface RecordClearanceEventInput {
|
||||
bookingId: string;
|
||||
action: string;
|
||||
/** Human sentence for the History tab, frozen at write time. */
|
||||
label: string;
|
||||
actorType?: ClearanceEventActorType;
|
||||
/** IAM user id (staff or portal customer); name is resolved here. */
|
||||
actorId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
|
||||
manager?: EntityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clearance History tab's write/read path. Every clearance mutation calls
|
||||
* {@link record} — document reviews, phased workflow steps, customer charges.
|
||||
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
|
||||
* transaction when a manager is passed, and otherwise a failed insert fails the
|
||||
* action, because a silent gap in an audit trail is worse than a retry.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceEventService {
|
||||
private readonly logger = new Logger(ClearanceEventService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async record(input: RecordClearanceEventInput): Promise<void> {
|
||||
const mg = input.manager ?? this.dataSource.manager;
|
||||
const actorName = input.actorId
|
||||
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
|
||||
input.actorId,
|
||||
) ?? null)
|
||||
: null;
|
||||
await mg.save(
|
||||
mg.create(BookingClearanceEvent, {
|
||||
bookingId: input.bookingId,
|
||||
action: input.action,
|
||||
label: input.label,
|
||||
actorType: input.actorType ?? 'STAFF',
|
||||
actorId: input.actorId ?? null,
|
||||
actorName,
|
||||
metadata: input.metadata ?? null,
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`clearance-history ${input.action} on booking ${input.bookingId}${
|
||||
actorName ? ` by ${actorName}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** History for one booking, newest first. */
|
||||
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingClearanceEvent)
|
||||
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||
|
||||
// Rows whose actor name failed to resolve at write time get one more try.
|
||||
const missing = rows
|
||||
.filter((r) => !r.actorName && r.actorId)
|
||||
.map((r) => r.actorId as string);
|
||||
const names = missing.length
|
||||
? await resolveIamUserNames(this.dataSource, missing).catch(
|
||||
() => new Map<string, string>(),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
label: r.label,
|
||||
actorType: r.actorType,
|
||||
actorName:
|
||||
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
|
||||
metadata: r.metadata ?? null,
|
||||
at: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -113,3 +113,36 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses after which clearance documents are closed: the shipment is paid
|
||||
* and moving. Everything before that — review, clearance ready, operation
|
||||
* request, batch selection, PNR, payment verification — still accepts new
|
||||
* customer documents and still lets GL review them.
|
||||
*/
|
||||
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'EXPIRED',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True while the customer may still attach clearance documents and GL may
|
||||
* still approve or query them.
|
||||
*
|
||||
* Clearance finalization is NOT the cut-off: a customs shipment keeps
|
||||
* collecting paperwork (amended invoices, revised packing lists, port
|
||||
* documents) right up to the final invoice being settled. Both the customer's
|
||||
* upload endpoint and GL's review endpoint gate on this one predicate, so the
|
||||
* two sides can never drift apart.
|
||||
*/
|
||||
export function clearanceDocumentsOpen(booking: Booking): boolean {
|
||||
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
|
||||
// Payment settled ahead of the status transition (webhook ordering).
|
||||
if (booking.paymentStatus === 'PAID') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
ConsolidationApprovalService,
|
||||
CONSOLIDATION_APPROVAL_PENDING,
|
||||
} from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||
* commercial call, so the pair is held for a human decision instead of going
|
||||
* straight to Operations.
|
||||
*
|
||||
* The invariants that matter: both halves are held and released TOGETHER (a
|
||||
* decision on one side of a shared wagon is meaningless without the other), and
|
||||
* a decided pairing cannot be decided twice.
|
||||
*/
|
||||
describe('ConsolidationApprovalService', () => {
|
||||
const PENDING = {
|
||||
id: 'ap-1',
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedBy: 'gl-user',
|
||||
};
|
||||
|
||||
function makeService(overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
} = {}) {
|
||||
const approvals = {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||
findById: jest.fn().mockResolvedValue(PENDING),
|
||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||
decide: jest.fn().mockResolvedValue(true),
|
||||
findQueue: jest.fn().mockResolvedValue([]),
|
||||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||
...overrides.approvals,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
({ id, reference: `BK-${id}` }) as Booking,
|
||||
),
|
||||
};
|
||||
const notifier = {
|
||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||
consolidationApprovedToStaff: jest.fn(),
|
||||
consolidationRejectedToStaff: jest.fn(),
|
||||
operationRequestedToStaff: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
|
||||
const service = new ConsolidationApprovalService(
|
||||
approvals as never,
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
notifier as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, approvals, bookingsRepository, notifier };
|
||||
}
|
||||
|
||||
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(approvals.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
requestedBy: 'gl-user',
|
||||
}),
|
||||
);
|
||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(
|
||||
notifier.consolidationApprovalRequestedToStaff,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open a second review for a pairing already pending', async () => {
|
||||
const { service, approvals } = makeService({
|
||||
approvals: {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(result).toBe(PENDING);
|
||||
expect(approvals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'approver-1',
|
||||
'looks fine',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
// Operations only learns about the pair now — the gate is what kept it out.
|
||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||
const { service, approvals, bookingsRepository } = makeService();
|
||||
|
||||
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
'approver-1',
|
||||
'partner cargo is wrong',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the requester approve their own pairing', async () => {
|
||||
// No maker-checker separation: the permission alone decides who may approve,
|
||||
// and the audit trail still records requester and approver separately.
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'gl-user');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'gl-user',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason to reject', async () => {
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||
/reason is required/i,
|
||||
);
|
||||
expect(approvals.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pairing that was already decided', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Approved,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already approved/i,
|
||||
);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||
// affects nothing and must not move the bookings.
|
||||
const { service } = makeService({
|
||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already decided by someone else/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
|
||||
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon under two separate invoices, so a person reviews the pairing
|
||||
* before Operations sees either half.
|
||||
*
|
||||
* Both halves are held and released TOGETHER — the wagon is shared, so a
|
||||
* decision on one is meaningless without the other. Every request is kept,
|
||||
* decided or not: the table is the audit trail of who approved which pairing,
|
||||
* when, and why.
|
||||
*
|
||||
* No maker-checker separation: whoever holds the approve permission may decide a
|
||||
* pairing, including the GL user who created it. The record of who requested and
|
||||
* who decided is still kept either way.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalService {
|
||||
private readonly logger = new Logger(ConsolidationApprovalService.name);
|
||||
|
||||
constructor(
|
||||
private readonly approvals: ConsolidationApprovalsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Park a newly consolidated pair for review instead of letting it continue to
|
||||
* Operations. Called from the completion path once the two halves are linked.
|
||||
*
|
||||
* Idempotent: a pair that already has an undecided request is left alone, so a
|
||||
* retried completion cannot open a second review of the same wagon.
|
||||
*/
|
||||
async requestApproval(
|
||||
bookingId: string,
|
||||
partnerBookingId: string,
|
||||
requestedBy: string | null,
|
||||
): Promise<ConsolidationApproval> {
|
||||
const existing = await this.approvals.findPendingForBooking(bookingId);
|
||||
if (existing) return existing;
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const partner = await this.bookingsService.findById(partnerBookingId);
|
||||
if (!booking || !partner) {
|
||||
throw new NotFoundException("Both bookings of the pair must exist.");
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
bookingId,
|
||||
partnerBookingId,
|
||||
requestedBy,
|
||||
scheduledDate: booking.scheduledDate ?? null,
|
||||
bookingReference: booking.reference ?? null,
|
||||
partnerBookingReference: partner.reference ?? null,
|
||||
});
|
||||
|
||||
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(partnerBookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
|
||||
this.notifier.consolidationApprovalRequestedToStaff(
|
||||
booking,
|
||||
partner.reference ?? partnerBookingId,
|
||||
);
|
||||
this.logger.log(
|
||||
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
|
||||
);
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pairing: both halves leave the gate and continue to Operations,
|
||||
* which is exactly where a non-consolidated booking would already be.
|
||||
*
|
||||
* All-or-nothing — the two status writes and the decision record share one
|
||||
* transaction, so the audit trail can never claim an approval that did not
|
||||
* take effect.
|
||||
*/
|
||||
async approve(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
note?: string,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const approval = await this.loadPending(approvalId);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationApprovedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
);
|
||||
// Operations only now learns about the pair — the gate is what kept it out.
|
||||
this.notifier.operationRequestedToStaff(booking);
|
||||
this.notifier.operationRequestedToStaff(partner);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
|
||||
* with the reason, so the cargo or the partner can be changed and resubmitted.
|
||||
*/
|
||||
async reject(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A reason is required to reject a consolidation.",
|
||||
);
|
||||
}
|
||||
const approval = await this.loadPending(approvalId);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy,
|
||||
reason.trim(),
|
||||
);
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.bookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationRejectedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
}
|
||||
|
||||
/** Full decision history for one booking — who decided what, and when. */
|
||||
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findAllForBooking(bookingId);
|
||||
}
|
||||
|
||||
/** The undecided request covering this booking, if any. */
|
||||
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
return approval;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
|
||||
/**
|
||||
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||
* decided rows are the audit trail of who approved which pairing and when.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalsRepository {
|
||||
private readonly repository: Repository<ConsolidationApproval>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repository = this.dataSource.getRepository(ConsolidationApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
* The undecided request covering `bookingId`, from EITHER side of the pair —
|
||||
* one row governs both halves, and the caller may hold either one.
|
||||
*/
|
||||
findPendingForBooking(
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({
|
||||
where: [
|
||||
{ bookingId, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: bookingId,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Every request touching this booking, newest first (the audit trail). */
|
||||
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: [{ bookingId }, { partnerBookingId: bookingId }],
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||
findQueue(): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: { status: ConsolidationApprovalStatus.Pending },
|
||||
relations: {
|
||||
booking: { company: true },
|
||||
partnerBooking: { company: true },
|
||||
},
|
||||
order: { requestedAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
create(input: {
|
||||
bookingId: string;
|
||||
partnerBookingId: string;
|
||||
requestedBy?: string | null;
|
||||
scheduledDate?: Date | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
}): Promise<ConsolidationApproval> {
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
...input,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the decision. Written only against a row still PENDING, so two
|
||||
* approvers racing on the same pairing cannot both succeed — the second
|
||||
* update matches nothing and the caller sees `false`.
|
||||
*/
|
||||
async decide(
|
||||
id: string,
|
||||
status:
|
||||
| ConsolidationApprovalStatus.Approved
|
||||
| ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: string | null,
|
||||
decisionNote?: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: decisionNote ?? null,
|
||||
},
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Undecided requests covering any of these bookings (list badging). */
|
||||
findPendingForBookings(
|
||||
bookingIds: string[],
|
||||
): Promise<ConsolidationApproval[]> {
|
||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsPositive, IsString, Length } from 'class-validator';
|
||||
|
||||
export class BillClearanceChargeDto {
|
||||
@ApiProperty({ example: 12500.5 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
@IsString()
|
||||
@Length(3, 8)
|
||||
currency!: string;
|
||||
}
|
||||
@@ -125,3 +125,59 @@ export class OperationReviewDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staff decision applied to BOTH halves of a consolidated pair. The two
|
||||
* bookings share a wagon, so they advance or cancel together — never one alone.
|
||||
*/
|
||||
export class PairedDecisionDto {
|
||||
@ApiProperty({
|
||||
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
|
||||
description: 'Which staff decision to apply to both bookings.',
|
||||
})
|
||||
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
|
||||
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
|
||||
|
||||
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Message to the customer (decision=requestChanges).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Contract validity window in days (decision=accept).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
validityDays?: number;
|
||||
}
|
||||
|
||||
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
|
||||
export class ApproveConsolidationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Optional note recorded with the approval.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
|
||||
export class RejectConsolidationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the pairing is rejected. Sent back to GL on both bookings.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const CLEARANCE_CHARGE_TYPES = ['PORT_CHARGES', 'MISCELLANEOUS'] as const;
|
||||
export type ClearanceChargeType = (typeof CLEARANCE_CHARGE_TYPES)[number];
|
||||
|
||||
export const CLEARANCE_CHARGE_STATUSES = [
|
||||
'DOC_UPLOADED',
|
||||
'BILLED',
|
||||
'SENT',
|
||||
'PAID',
|
||||
] as const;
|
||||
export type ClearanceChargeStatus = (typeof CLEARANCE_CHARGE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Post-finalization clearance charge billed to the customer — at most one
|
||||
* PORT_CHARGES and one MISCELLANEOUS row per booking. GL Djibouti uploads the
|
||||
* port-charges document (DOC_UPLOADED); GL Ethiopia sets amount + currency
|
||||
* (BILLED) and issues the invoice (SENT); the billing `clearance_charge.invoice.paid`
|
||||
* event marks it PAID. MISCELLANEOUS is created whole by GL Ethiopia and only
|
||||
* after the port charge is paid.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_charge' })
|
||||
@Index(['bookingId', 'type'], { unique: true })
|
||||
export class BookingClearanceCharge extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: ClearanceChargeType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DOC_UPLOADED' })
|
||||
status!: ClearanceChargeStatus;
|
||||
|
||||
/** The supporting charge document (FileRecord). */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
amount?: string | null;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
/** The payable invoice issued for this charge (null until SENT). */
|
||||
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
|
||||
invoiceId?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_by_staff_id', type: 'uuid', nullable: true })
|
||||
uploadedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'uploaded_at', type: 'timestamptz', nullable: true })
|
||||
uploadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'billed_by_staff_id', type: 'uuid', nullable: true })
|
||||
billedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'billed_at', type: 'timestamptz', nullable: true })
|
||||
billedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
|
||||
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
|
||||
|
||||
/**
|
||||
* One row per action in a booking's clearance flow — the History tab's source
|
||||
* of truth. Written explicitly (and, where the caller runs one, inside the
|
||||
* caller's transaction) by every clearance mutation: document review, phased
|
||||
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
|
||||
* charges. `action` is a stable machine code; `label` is the human sentence
|
||||
* rendered as written, so old rows survive later wording changes.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
|
||||
@Index(['bookingId', 'createdAt'])
|
||||
export class BookingClearanceEvent extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
|
||||
@Column({ name: 'action', type: 'varchar', length: 64 })
|
||||
action!: string;
|
||||
|
||||
/** Human sentence shown in the History tab, frozen at write time. */
|
||||
@Column({ name: 'label', type: 'varchar', length: 500 })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
|
||||
actorType!: ClearanceEventActorType;
|
||||
|
||||
/** IAM user id of the actor (null for SYSTEM events). */
|
||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/** Display name resolved at write time (iam.users); null when unresolvable. */
|
||||
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
|
||||
actorName?: string | null;
|
||||
|
||||
/** Action details: fileKey, note, amount, currency, file names, … */
|
||||
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
|
||||
// the booking enters the batch holding pool.
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Shared-wagon review gate: a consolidated pair waits for a human decision
|
||||
// before either half reaches Operations. Two customers' cargo on one wagon is
|
||||
// a commercial call, so it is never auto-advanced.
|
||||
'CONSOLIDATION_APPROVAL_PENDING',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "./booking.entity";
|
||||
|
||||
export enum ConsolidationApprovalStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval gate for a consolidated (shared-wagon) booking pair.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon, under two separate invoices and two separate liabilities. That
|
||||
* pairing is a commercial decision, so a person reviews it before Operations
|
||||
* sees either half.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves — so nobody can
|
||||
* approve one side of a shared wagon and leave the other pending. Rows are never
|
||||
* deleted: decided rows are the audit trail of who approved which pairing, when,
|
||||
* and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "consolidation_approvals" })
|
||||
@Index(["bookingId", "status"])
|
||||
@Index(["status"])
|
||||
export class ConsolidationApproval extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** The other half of the shared wagon. */
|
||||
@Column({ name: "partner_booking_id", type: "uuid" })
|
||||
partnerBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "partner_booking_id" })
|
||||
partnerBooking?: Booking;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ConsolidationApprovalStatus,
|
||||
default: ConsolidationApprovalStatus.Pending,
|
||||
})
|
||||
status!: ConsolidationApprovalStatus;
|
||||
|
||||
/** IAM user id of the GL staff whose completion created the pairing. */
|
||||
@Column({ name: "requested_by", type: "uuid", nullable: true })
|
||||
requestedBy?: string | null;
|
||||
|
||||
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
|
||||
requestedAt!: Date;
|
||||
|
||||
/** IAM user id of the approver; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
/** Why it was approved or rejected. Required on reject, optional on approve. */
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────────────
|
||||
// Copied at request time so the audit trail still reads correctly after the
|
||||
// bookings themselves move on (rebooked to another day, cancelled, renamed).
|
||||
|
||||
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
bookingReference?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "partner_booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
partnerBookingReference?: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user