]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
+ [...tr[1].matchAll(/| ]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
+ );
+ const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
+ const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
+ .map((m) => htmlToText(m[1]))
+ .filter(Boolean);
+ const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
+
+ const landscape = headers.length > 7;
+ const page = landscape ? PageSize.landscape : PageSize.portrait;
+ const M = 32;
+ const contentW = page.width - M * 2;
+ const right = page.width - M;
+ const ops: string[] = [];
+
+ // Header
+ ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
+ ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
+ ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
+ if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
+ if (metaRef) {
+ ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
+ ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
+ }
+ if (generated) {
+ ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
+ }
+ ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
+
+ // Summary tiles
+ let y = page.height - 100;
+ if (tiles.length) {
+ const cols = landscape ? 6 : 4;
+ const tileW = contentW / cols;
+ const tileH = 32;
+ tiles.forEach(([label, value], i) => {
+ const col = i % cols;
+ if (col === 0 && i > 0) y -= tileH;
+ const x = M + col * tileW;
+ ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
+ ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
+ ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
+ });
+ y -= tileH + 12;
+ }
+
+ // Table
+ if (headers.length) {
+ const colW = contentW / headers.length;
+ const headerH = 16;
+ const rowH = 14;
+ const cellChars = Math.max(4, Math.floor(colW / 3.9));
+ ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
+ headers.forEach((h, c) =>
+ ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
+ );
+ y -= headerH;
+
+ let shown = 0;
+ for (const row of rows) {
+ if (y < 96) break;
+ ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
+ headers.forEach((_h, c) => {
+ if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
+ const cell = row[c] ?? "";
+ if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
+ });
+ y -= rowH;
+ shown += 1;
+ }
+ if (shown < rows.length) {
+ ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
+ }
+ }
+
+ // Notice (verification clause)
+ if (notice) {
+ ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
+ wrapText(notice, landscape ? 155 : 104)
+ .slice(0, 2)
+ .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
+ }
+
+ // Signatures
+ const sigW = contentW / signatures.length;
+ signatures.forEach((s, i) => {
+ const x = M + i * sigW;
+ ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
+ ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
+ });
+
+ return assembleSinglePagePdf(ops, page);
+}
+
+/** Greedy word-wrap to a maximum character width. */
+export function wrapText(text: string, maxChars: number): string[] {
+ const out: string[] = [];
+ for (const raw of String(text ?? "").split("\n")) {
+ const words = raw.split(/\s+/).filter(Boolean);
+ let line = "";
+ for (const word of words) {
+ const next = line ? `${line} ${word}` : word;
+ if (next.length > maxChars && line) {
+ out.push(line);
+ line = word;
+ } else {
+ line = next;
+ }
+ }
+ if (line) out.push(line);
+ }
+ return out.length ? out : [""];
+}
+
+/** A4 page sizes in PDF points. */
+export const PageSize = {
+ portrait: { width: 595, height: 842 },
+ landscape: { width: 842, height: 595 },
+} as const;
+
+/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
+export function assembleSinglePagePdf(
+ ops: string[],
+ page: { width: number; height: number } = PageSize.portrait,
+): Buffer {
+ const stream = ops.join("\n");
+ const objects = [
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
+ `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
+ ];
+
+ let pdf = "%PDF-1.4\n";
+ const offsets: number[] = [0];
+ objects.forEach((object, index) => {
+ offsets.push(Buffer.byteLength(pdf, "latin1"));
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ });
+ while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
+ pdf += "% fallback padding\n";
+ }
+ const xrefOffset = Buffer.byteLength(pdf, "latin1");
+ pdf += `xref\n0 ${objects.length + 1}\n`;
+ pdf += "0000000000 65535 f \n";
+ for (const offset of offsets.slice(1)) {
+ pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
+ }
+ pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
+ return Buffer.from(pdf, "latin1");
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts
new file mode 100644
index 000000000..a4546e301
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts
@@ -0,0 +1,307 @@
+import { Injectable, Logger } from '@nestjs/common';
+import {
+ NotificationAudience,
+ NotificationType,
+ NotifyInput,
+} from '@edr/types';
+
+import { Booking } from './entities/booking.entity';
+import { NotificationsService } from '../notifications/notifications.service';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
+
+/**
+ * Customer + staff notifications for the booking lifecycle: review, clearance
+ * and operation flow. Every customer event fans out over SMS + email (direct)
+ * and a persisted in-app notification deep-linking to the booking detail page;
+ * staff events land in the backoffice inbox. All sends are fire-and-forget and
+ * never throw — a notification failure must not break a booking transition.
+ *
+ * NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
+ * displaced) are handled separately by {@link BookingNotifierService} in
+ * train-scheduling.
+ */
+@Injectable()
+export class BookingLifecycleNotifierService {
+ private readonly logger = new Logger(BookingLifecycleNotifierService.name);
+
+ constructor(
+ private readonly notifications: NotificationsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
+
+ private ref(b: Booking): string {
+ return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
+ }
+
+ /** Send SMS + email to the booking's company contact; log-only on failure. */
+ private async notifyContact(
+ b: Booking,
+ message: string,
+ logLabel: string,
+ ): Promise {
+ this.logger.log(`${logLabel} — ${this.ref(b)}`);
+ const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
+ const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
+
+ if (phone) {
+ try {
+ await this.notifications.directSend('sms', phone, message);
+ } catch (err) {
+ this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
+ }
+ }
+ if (email) {
+ try {
+ await this.notifications.directSend('email', email, message);
+ } catch (err) {
+ this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
+ }
+ }
+ if (!phone && !email) {
+ this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
+ }
+ }
+
+ /** Persist + push an in-app item to all portal users of the booking's company. */
+ private inApp(
+ b: Booking,
+ title: string,
+ body: string,
+ overrides: Partial = {},
+ ): void {
+ if (!b.companyId) return; // government/unlinked bookings have no portal users
+ void this.inbox.notify({
+ recipients: { companyId: b.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.BOOKING_STATUS,
+ title,
+ body,
+ link: `/bookings/${b.id}`,
+ data: { bookingId: b.id, reference: b.reference },
+ ...overrides,
+ });
+ }
+
+ /** Persist + push an in-app item to every backoffice staff user. */
+ private inAppStaff(
+ b: Booking,
+ title: string,
+ body: string,
+ overrides: Partial = {},
+ ): void {
+ void this.inbox.notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title,
+ body,
+ link: `/dashboard/booking-requests/${b.id}`,
+ data: { bookingId: b.id, reference: b.reference },
+ ...overrides,
+ });
+ }
+
+ // ── Customer-facing lifecycle events ───────────────────────────────────────
+
+ /** Line staff accepted intake → booking is under approval. */
+ accepted(b: Booking): void {
+ const msg =
+ `Your booking ${b.reference} has been accepted and is now under approval. ` +
+ `We will notify you once it is approved.`;
+ void this.notifyContact(b, msg, 'ACCEPTED');
+ this.inApp(b, 'Booking accepted', msg);
+ }
+
+ /** All approval steps complete → contract generated, ready for customer to sign. */
+ approved(b: Booking): void {
+ const msg =
+ `Your booking ${b.reference} has been approved. ` +
+ `Please review and sign your contract from the portal.`;
+ void this.notifyContact(b, msg, 'APPROVED');
+ this.inApp(b, 'Booking approved', msg);
+ }
+
+ /** Staff rejected the booking (intake or approval step). */
+ rejected(b: Booking, reason: string): void {
+ const msg =
+ `Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
+ `Please contact us for details.`;
+ void this.notifyContact(b, msg, 'REJECTED');
+ this.inApp(b, 'Booking rejected', msg);
+ }
+
+ /** Staff requested changes before approval. */
+ changesRequested(b: Booking, note: string): void {
+ const msg =
+ `Changes were requested on your booking ${b.reference}: ${note}. ` +
+ `Please update and resubmit from the portal.`;
+ void this.notifyContact(b, msg, 'CHANGES REQUESTED');
+ this.inApp(b, 'Booking changes requested', msg);
+ }
+
+ /** A clearance document was queried and needs the customer to re-upload. */
+ documentQueried(b: Booking, fileKey: string, note: string): void {
+ const msg =
+ `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
+ `${note}. Please re-upload from the portal.`;
+ void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
+ this.inApp(b, 'Document queried', msg, {
+ type: NotificationType.DOCUMENT_ACTION,
+ });
+ }
+
+ /** Clearance finalized → customer can proceed to request operation. */
+ clearanceReady(b: Booking): void {
+ const msg =
+ `Clearance for booking ${b.reference} is complete. ` +
+ `You can now proceed to request operation from the portal.`;
+ void this.notifyContact(b, msg, 'CLEARANCE READY');
+ this.inApp(b, 'Clearance complete', msg, {
+ type: NotificationType.CLEARANCE_DECISION,
+ });
+ }
+
+ /** Operations returned the operation request for changes. */
+ operationChangesRequested(b: Booking, note: string): void {
+ const msg =
+ `Your operation request for booking ${b.reference} needs changes: ${note}. ` +
+ `Please update and resubmit from the portal.`;
+ void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
+ this.inApp(b, 'Operation request needs changes', msg);
+ }
+
+ /** Operation accepted → invoice ready; await payment / booking window. */
+ operationAccepted(b: Booking): void {
+ const msg =
+ `Your operation request for booking ${b.reference} has been accepted. ` +
+ `An invoice has been prepared — watch for the payment window to secure your slot.`;
+ void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
+ this.inApp(b, 'Operation request accepted', msg);
+ }
+
+ /** Shipment started → in transit. */
+ inTransit(b: Booking): void {
+ const msg = `Your shipment for booking ${b.reference} is now in transit.`;
+ void this.notifyContact(b, msg, 'IN TRANSIT');
+ this.inApp(b, 'Shipment in transit', msg);
+ }
+
+ /** Shipment delivered → completed. */
+ completed(b: Booking): void {
+ const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
+ void this.notifyContact(b, msg, 'COMPLETED');
+ this.inApp(b, 'Shipment delivered', msg);
+ }
+
+ /** Booking cancelled. */
+ cancelled(b: Booking, reason: string): void {
+ const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
+ void this.notifyContact(b, msg, 'CANCELLED');
+ this.inApp(b, 'Booking cancelled', msg);
+ }
+
+ // ── Clearance milestones needing customer action ──────────────────────────
+
+ /** GL advised duty & tax — the customer must pay and upload the slip. */
+ dutyAdvised(b: Booking, amount: number, currency: string): void {
+ const msg =
+ `Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
+ `Please pay and upload the payment slip from the portal.`;
+ void this.notifyContact(b, msg, 'DUTY ADVISED');
+ this.inApp(b, 'Duty & tax advised', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
+ }
+
+ /** GL advised the post-arrival additional duty round (import). */
+ secondDutyAdvised(b: Booking, amount: number, currency: string): void {
+ const msg =
+ `Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
+ `Please pay and upload the payment slip from the portal.`;
+ void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
+ this.inApp(b, 'Additional duty & tax advised', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
+ }
+
+ /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
+ finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
+ const msg =
+ `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
+ `Please pay and upload the payment slip from the portal.`;
+ void this.notifyContact(b, msg, 'FINAL INVOICE');
+ this.inApp(b, 'Final invoice issued', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
+ }
+
+ /** GL confirmed the final-invoice payment slip. */
+ finalInvoicePaid(b: Booking): void {
+ const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
+ void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
+ this.inApp(b, 'Final invoice paid', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
+ }
+
+ // ── Staff-facing (backoffice inbox) ────────────────────────────────────────
+
+ /** Customer submitted a booking for review. */
+ submittedToStaff(b: Booking): void {
+ this.inAppStaff(
+ b,
+ 'New booking submitted',
+ `Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
+ );
+ }
+
+ /** Customer signed the booking contract. */
+ customerSignedToStaff(b: Booking): void {
+ this.inAppStaff(
+ b,
+ 'Customer signed booking contract',
+ `The contract for booking ${this.ref(b)} was signed by the customer.`,
+ );
+ }
+
+ /** Customer requested operation (picked a shipment day). */
+ operationRequestedToStaff(b: Booking): void {
+ this.inAppStaff(
+ b,
+ 'Operation requested',
+ `Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
+ );
+ }
+
+ /** Customer uploaded clearance documents — review is next. */
+ clearanceDocsUploadedToStaff(b: Booking): void {
+ this.inAppStaff(
+ b,
+ 'Clearance documents uploaded',
+ `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
+ {
+ type: NotificationType.CLEARANCE_REVIEW,
+ link: `/dashboard/bookings/${b.id}/clearance`,
+ },
+ );
+ }
+
+ /** Customer uploaded a duty/tax payment slip — GL verifies it. */
+ dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
+ const label =
+ round === 'final'
+ ? 'final invoice'
+ : round === 'second'
+ ? 'additional duty & tax'
+ : 'duty & tax';
+ this.inAppStaff(
+ b,
+ 'Payment slip uploaded',
+ `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
+ {
+ type: NotificationType.PAYMENT_RECEIVED,
+ link: `/dashboard/bookings/${b.id}/clearance`,
+ },
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
index 3c535f450..607a0d7a4 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts
@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
- {} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
- {} as never,
+ {} as never, // workflowService
+ {} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
+ {
+ accepted: jest.fn(),
+ approved: jest.fn(),
+ rejected: jest.fn(),
+ changesRequested: jest.fn(),
+ documentQueried: jest.fn(),
+ clearanceReady: jest.fn(),
+ operationChangesRequested: jest.fn(),
+ operationAccepted: jest.fn(),
+ inTransit: jest.fn(),
+ completed: jest.fn(),
+ cancelled: jest.fn(),
+ submittedToStaff: jest.fn(),
+ customerSignedToStaff: jest.fn(),
+ operationRequestedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
index 9f9aa5713..72c83e136 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts
@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
- {} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
- {} as never,
+ {} as never, // workflowService
+ {} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
+ {
+ accepted: jest.fn(),
+ approved: jest.fn(),
+ rejected: jest.fn(),
+ changesRequested: jest.fn(),
+ documentQueried: jest.fn(),
+ clearanceReady: jest.fn(),
+ operationChangesRequested: jest.fn(),
+ operationAccepted: jest.fn(),
+ inTransit: jest.fn(),
+ completed: jest.fn(),
+ cancelled: jest.fn(),
+ submittedToStaff: jest.fn(),
+ customerSignedToStaff: jest.fn(),
+ operationRequestedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
return { service, bookingsRepository };
}
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
- {} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
- {} as never,
+ {} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
- {} as never,
+ {} as never, // workflowService
+ {} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
+ {
+ accepted: jest.fn(),
+ approved: jest.fn(),
+ rejected: jest.fn(),
+ changesRequested: jest.fn(),
+ documentQueried: jest.fn(),
+ clearanceReady: jest.fn(),
+ operationChangesRequested: jest.fn(),
+ operationAccepted: jest.fn(),
+ inTransit: jest.fn(),
+ completed: jest.fn(),
+ cancelled: jest.fn(),
+ submittedToStaff: jest.fn(),
+ customerSignedToStaff: jest.fn(),
+ operationRequestedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
return { service, bookingsRepository };
}
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
- {} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
- {} as never,
+ {} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
- {} as never,
+ {} as never, // workflowService
+ {} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
+ {
+ accepted: jest.fn(),
+ approved: jest.fn(),
+ rejected: jest.fn(),
+ changesRequested: jest.fn(),
+ documentQueried: jest.fn(),
+ clearanceReady: jest.fn(),
+ operationChangesRequested: jest.fn(),
+ operationAccepted: jest.fn(),
+ inTransit: jest.fn(),
+ completed: jest.fn(),
+ cancelled: jest.fn(),
+ submittedToStaff: jest.fn(),
+ customerSignedToStaff: jest.fn(),
+ operationRequestedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
return { service, bookingsRepository, filesService };
}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
index ea3618a08..9201e4fa9 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts
@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
- * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
- * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
+ * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
+ * domestic bookings wait for their booking-day window cycle (no immediate
+ * batch enqueue at accept time).
+ * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
+ reference: 'BKG-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
+ pickExportSchedule: jest.fn(),
+ acceptExportBooking: jest.fn(),
+ };
+ const invoiceService = {
+ ensureInvoiceForBooking: jest
+ .fn()
+ .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
+ updateStatus: jest.fn().mockResolvedValue(undefined),
};
const service = new BookingTransitionService(
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
- {} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
- {} as never,
+ {} as never, // workflowService
+ invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
+ {
+ accepted: jest.fn(),
+ approved: jest.fn(),
+ rejected: jest.fn(),
+ changesRequested: jest.fn(),
+ documentQueried: jest.fn(),
+ clearanceReady: jest.fn(),
+ operationChangesRequested: jest.fn(),
+ operationAccepted: jest.fn(),
+ inTransit: jest.fn(),
+ completed: jest.fn(),
+ cancelled: jest.fn(),
+ submittedToStaff: jest.fn(),
+ customerSignedToStaff: jest.fn(),
+ operationRequestedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
- return { service, bookingsRepository, bookingBatchService };
+ return { service, bookingsRepository, bookingBatchService, invoiceService };
}
- it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
- const { service, bookingsRepository, bookingBatchService } =
+ it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
+ const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
- expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
+ expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
+ // Import/domestic train bookings are batched by the window cycle later —
+ // never enqueued directly at accept time.
+ expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
+ expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
});
- it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
- const { service, bookingsRepository, bookingBatchService } =
+ it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
+ const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
+ expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 06edbf04e..b5c277073 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -4,6 +4,7 @@ import {
Inject,
Injectable,
Logger,
+ Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
+import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
+import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
@@ -53,7 +56,8 @@ export class BookingTransitionService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
-
+ private readonly notifier: BookingLifecycleNotifierService,
+ @Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -124,6 +128,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
+ if (finalBooking.status === "SUBMITTED") {
+ this.notifier.submittedToStaff(finalBooking);
+ }
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -204,6 +211,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
+ if (finalBooking.status === "SUBMITTED") {
+ this.notifier.submittedToStaff(finalBooking);
+ }
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -233,7 +243,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CHANGES_REQUESTED",
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.changesRequested(fresh, note);
+ return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
@@ -284,7 +296,9 @@ export class BookingTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.accepted(fresh);
+ return fresh;
}
async staffReject(
@@ -305,7 +319,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.rejected(fresh, reason);
+ return fresh;
}
async approveStep(
@@ -394,7 +410,9 @@ export class BookingTransitionService {
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
- return this.bookingsService.findById(generated.id);
+ const fresh = await this.bookingsService.findById(generated.id);
+ this.notifier.approved(fresh);
+ return fresh;
}
return this.bookingsService.findById(bookingId);
@@ -435,7 +453,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.rejected(fresh, reason);
+ return fresh;
}
async customerSign(bookingId: string): Promise {
@@ -446,7 +466,9 @@ export class BookingTransitionService {
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.customerSignedToStaff(fresh);
+ return fresh;
}
async startTransit(bookingId: string): Promise {
@@ -456,7 +478,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT",
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.inTransit(fresh);
+ return fresh;
}
async complete(bookingId: string): Promise {
@@ -467,7 +491,28 @@ export class BookingTransitionService {
status: "COMPLETED",
endDate: new Date(),
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.completed(fresh);
+ // Customer tracking: close out the tail milestones so a finished shipment
+ // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
+ // implied by delivery; a storage invoice that was never raised is skipped
+ // (storage billing does not apply to every shipment). All doc-trigger /
+ // best-effort — a booking without milestone rows is untouched.
+ if (this.milestoneService) {
+ for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
+ try {
+ await this.milestoneService.completeByDocTrigger({ bookingId }, code);
+ } catch {
+ /* tracking must never block completion */
+ }
+ }
+ try {
+ await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
+ } catch {
+ /* no such milestone row (export / non-customs) — fine */
+ }
+ }
+ return fresh;
}
async cancel(bookingId: string, reason: string): Promise {
@@ -491,7 +536,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
- return this.bookingsService.findById(updated!.id);
+ const fresh = await this.bookingsService.findById(updated!.id);
+ this.notifier.cancelled(fresh, reason);
+ return fresh;
}
/**
@@ -723,7 +770,9 @@ export class BookingTransitionService {
} as never);
}
- return this.bookingsService.findById(bookingId);
+ const fresh = await this.bookingsService.findById(bookingId);
+ this.notifier.clearanceDocsUploadedToStaff(fresh);
+ return fresh;
}
/**
@@ -824,6 +873,9 @@ export class BookingTransitionService {
}
const updated = await this.bookingsService.findById(bookingId);
+ if (status === "QUERIED") {
+ this.notifier.documentQueried(updated, fileKey, note ?? '');
+ }
if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
@@ -912,7 +964,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
- return this.bookingsService.findById(bookingId);
+ const fresh = await this.bookingsService.findById(bookingId);
+ this.notifier.clearanceReady(fresh);
+ return fresh;
}
/**
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
- return this.bookingsService.findById(bookingId);
+ const fresh = await this.bookingsService.findById(bookingId);
+ this.notifier.operationRequestedToStaff(fresh);
+ return fresh;
}
/**
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_CHANGES_REQUESTED",
} as never);
- return this.bookingsService.findById(bookingId);
+ const fresh = await this.bookingsService.findById(bookingId);
+ this.notifier.operationChangesRequested(fresh, options.note);
+ return fresh;
}
// ACCEPT — enter the batch holding pool.
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
- return this.bookingsService.findById(booking.id);
+ const roadFresh = await this.bookingsService.findById(booking.id);
+ this.notifier.operationAccepted(roadFresh);
+ return roadFresh;
}
await this.bookingsRepository.update(booking.id, {
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
- return this.bookingsService.findById(booking.id);
+ const trainFresh = await this.bookingsService.findById(booking.id);
+ this.notifier.operationAccepted(trainFresh);
+ return trainFresh;
}
async enrichBookingResponse(booking: Booking): Promise<
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index 106b7ed5b..444ac578a 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -15,11 +15,13 @@ import {
UnauthorizedException,
UploadedFile,
UploadedFiles,
+ UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
-import { BookingStaff } from '../../common/booking-guards';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { BookingStaff, BookingView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -64,6 +66,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
+import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
@@ -194,6 +197,7 @@ export class BookingsController {
}
@Get("by-company/:companyId/customer-view")
+ @BookingView()
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
@@ -204,6 +208,7 @@ export class BookingsController {
}
@Get("list-summary")
+ @BookingView()
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
@@ -225,6 +230,7 @@ export class BookingsController {
}
@Get("queues/:queue")
+ @BookingView()
@ApiOperation({
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
@@ -358,6 +364,33 @@ export class BookingsController {
return this.customerTruckService.removeTruck(id, assignmentId);
}
+ @Get(':id/customer-trucks/loadable-containers')
+ @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
+ async loadableContainers(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
+ }
+ return this.customerTruckService.getLoadableContainers(id);
+ }
+
+ @Post(':id/customer-trucks/:assignmentId/load')
+ @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
+ async loadCustomerTruck(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('assignmentId', ParseUUIDPipe) assignmentId: string,
+ @Body() dto: LoadCustomerTruckDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ throw new ForbiddenException('Only warehouse staff can load a truck');
+ }
+ return this.customerTruckService.loadTruck(id, assignmentId, dto);
+ }
+
@Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
@@ -928,12 +961,18 @@ export class BookingsController {
}
@Post(":id/contract/sign")
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
+ @CurrentUser() user: TCurrentUser,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
+ // Staff signature needs the sign permission; customer signs their own booking.
+ if (dto.role !== "CUSTOMER") {
+ assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
+ }
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 2cb10ce8e..61dc78e13 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
+import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingTransitionService } from './booking-transition.service';
+import { NotificationsModule } from '../notifications/notifications.module';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
+ NotificationsModule,
+ NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
+ BookingLifecycleNotifierService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
+ BookingLifecycleNotifierService,
CustomerTruckService,
ContainerReceiptService,
],
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 7a6169b7b..1fb37dc31 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -176,6 +176,44 @@ export class BookingsService {
}
/** Resolve trade direction from yard countries; reject client mismatch. */
+ /**
+ * An intercity corridor is valid when both yards are Ethiopian and at least
+ * one non-retired route passes the origin strictly before the destination in
+ * its milestone order — that is the corridor an import/export train can
+ * serve the booking on.
+ */
+ private async assertIntercityCorridorExists(
+ originYardId: string,
+ destinationYardId: string,
+ ): Promise {
+ const yards = await this.dataSource.getRepository(Yard).find({
+ where: { id: In([originYardId, destinationYardId]) },
+ });
+ if (yards.some((y) => y.country !== 'Ethiopia')) {
+ throw new BadRequestException(
+ 'Intercity bookings only run between Ethiopian yards',
+ );
+ }
+ const rows: Array<{ id: string }> = await this.dataSource.query(
+ `SELECT r.id
+ FROM freight.routes r
+ JOIN freight.route_milestones mo
+ ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
+ JOIN freight.route_milestones md
+ ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
+ WHERE mo.sequence_no < md.sequence_no
+ AND r.status = 'AVAILABLE'
+ AND r.deleted_at IS NULL
+ LIMIT 1`,
+ [originYardId, destinationYardId],
+ );
+ if (rows.length === 0) {
+ throw new BadRequestException(
+ 'No route passes through this origin and destination in order — intercity service is not available on this corridor',
+ );
+ }
+ }
+
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection,
);
+ // Intercity (DOMESTIC) bookings never get their own train — they ride on a
+ // passing import/export train, so there is no booking window and no date to
+ // pin. All we require at creation is that the corridor actually lies on a
+ // route (origin before destination in some route's milestone order); staff
+ // accept the booking onto a concrete train at finalize time.
+ if (tradeDirection === 'DOMESTIC') {
+ if (dto.scheduledDate || dto.trainScheduleId) {
+ throw new BadRequestException(
+ 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
+ );
+ }
+ await this.assertIntercityCorridorExists(
+ dto.originYardId,
+ dto.destinationYardId,
+ );
+ }
+
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
@@ -1333,6 +1388,17 @@ export class BookingsService {
);
}
+ // Surface the assigned train's operational status so the portal stepper
+ // can show the Arrival stage: the booking status stays IN_TRANSIT from
+ // dispatch until delivery, so arrival is only knowable from the schedule.
+ if (booking.trainScheduleId) {
+ const schedule = await this.dataSource
+ .getRepository(TrainSchedule)
+ .findOne({ where: { id: booking.trainScheduleId } });
+ (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
+ schedule?.status ?? null;
+ }
+
return booking;
}
diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts
index fde3ab797..fea73603a 100644
--- a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts
@@ -38,7 +38,7 @@ export class ContainerReceiptService {
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
- FROM freight.booking_containers bc,
+ FROM freight.booking_container bc,
freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu
- JOIN freight.booking_containers bc
+ JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
- JOIN freight.booking_containers bc
+ JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu
- JOIN freight.booking_containers bc
+ JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId],
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu
- JOIN freight.booking_containers bc
+ JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId],
diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
index 5d0650219..6f0ec6f55 100644
--- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
@@ -198,6 +198,87 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
+ /** Booking container numbers not yet loaded onto any truck. */
+ async getLoadableContainers(bookingId: string): Promise {
+ const [all, assigned] = await Promise.all([
+ this.bookingContainerNumbers(bookingId),
+ this.assignedContainerNumbers(bookingId),
+ ]);
+ const taken = new Set(assigned);
+ return all.filter((n) => !taken.has(n));
+ }
+
+ /**
+ * Truck_dispatch (load): assign the selected containers to a truck after it has
+ * arrived, and set a provisional gross weight from their VGM. The truck is
+ * weighed for real on departure. Locked once the truck has left.
+ */
+ async loadTruck(
+ bookingId: string,
+ assignmentId: string,
+ dto: { containerNumbers: string[] },
+ ): Promise {
+ await this.loadBookingGuard(bookingId);
+ const assignment = await this.assignments.findByIdWithContainers(assignmentId);
+ if (!assignment || assignment.bookingId !== bookingId) {
+ throw new NotFoundException('Truck assignment not found for this booking');
+ }
+ if (assignment.departedAt) {
+ throw new ConflictException('This truck has already left — its load is locked');
+ }
+
+ const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
+ if (!requested.length) {
+ throw new BadRequestException('Select at least one container to load onto the truck');
+ }
+ const bookingNumbers = await this.bookingContainerNumbers(bookingId);
+ for (const n of requested) {
+ if (!bookingNumbers.includes(n)) {
+ throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
+ }
+ }
+ const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
+ for (const n of requested) {
+ if (elsewhere.includes(n)) {
+ throw new ConflictException(`Container ${n} is already loaded onto another truck`);
+ }
+ }
+
+ const grossKg = await this.vgmKgForContainers(bookingId, requested);
+ await this.dataSource.transaction(async (manager) => {
+ await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
+ await manager.getRepository(CustomerTruckContainer).save(
+ requested.map((containerNumber) =>
+ manager.getRepository(CustomerTruckContainer).create({
+ assignmentId,
+ bookingId,
+ containerNumber,
+ }),
+ ),
+ );
+ // Provisional gross from the loaded containers' VGM — overridden by the
+ // weighed gross on departure.
+ await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
+ grossWeightKg: grossKg,
+ });
+ });
+ return this.listTrucks(bookingId);
+ }
+
+ private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise {
+ const [row]: Array<{ kg: string }> = await this.dataSource.query(
+ `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
+ FROM freight.booking_container_units bcu
+ JOIN freight.booking_container bc
+ ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
+ WHERE bc.booking_id = $1
+ AND bcu.container_number = ANY($2::varchar[])
+ AND bcu.deleted_at IS NULL`,
+ [bookingId, numbers],
+ );
+ return Number(row?.kg ?? 0);
+ }
+
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level
@@ -288,7 +369,7 @@ export class CustomerTruckService {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
- JOIN freight.booking_containers bc
+ JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts
new file mode 100644
index 000000000..11c80f687
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts
@@ -0,0 +1,13 @@
+import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
+
+/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
+export class LoadCustomerTruckDto {
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayUnique()
+ @Matches(/^[A-Z]{4}\d{7}$/, {
+ each: true,
+ message: 'each container number must match ISO container format, e.g. ABCD1234567',
+ })
+ containerNumbers!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
index 2b0126003..7b5cb77d6 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
@@ -85,6 +85,13 @@ function makeService(overrides?: {
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
+ {
+ dutyAdvised: jest.fn(),
+ clearanceReady: jest.fn(),
+ documentQueried: jest.fn(),
+ dutySlipUploadedToStaff: jest.fn(),
+ clearanceDocsUploadedToStaff: jest.fn(),
+ } as never, // notifier
);
return {
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService();
- await service.adviseDuty('b-general', {
- dutyRequired: true,
- amount: 1500,
- currency: 'ETB',
- declarationSerial: 'DS-1',
- });
+ await service.adviseDuty(
+ 'b-general',
+ {
+ dutyRequired: true,
+ amount: 1500,
+ currency: 'ETB',
+ declarationSerial: 'DS-1',
+ },
+ undefined,
+ // The duty notice attachment is now mandatory when duty applies.
+ { fieldname: 'duty_tax_notice' } as Express.Multer.File,
+ );
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
'b-general',
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
index 61ac93925..59dad2248 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
+import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
@@ -100,6 +101,7 @@ export class BookingClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
+ private readonly notifier: BookingLifecycleNotifierService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise {
@@ -174,7 +176,28 @@ export class BookingClearanceService {
}
const allApproved = await this.isClearanceFullyApproved(booking);
- const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
+ let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
+
+ // Self-heal: a booking that has settled its freight payment must have
+ // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
+ // export FCFS booking (linked to its train at booking time) paid via the
+ // prepaid invoice can leave the milestone PENDING — the clearance "Payment &
+ // wagon allocation" step then never ticks. Backfill it here so already-stuck
+ // rows recover without a migration; idempotent (no-op once COMPLETED).
+ const paymentSettled = milestones.find(
+ (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
+ );
+ if (
+ paymentSettled &&
+ paymentSettled.status === 'PENDING' &&
+ (booking.paymentStatus === 'PAID' || booking.status === 'PAID')
+ ) {
+ await this.workflowService.completeMilestoneForBooking(
+ bookingId,
+ 'FREIGHT_PAYMENT_SETTLED',
+ );
+ milestones = await this.workflowService.listMilestonesForBooking(bookingId);
+ }
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
@@ -414,6 +437,7 @@ export class BookingClearanceService {
},
userId,
);
+ this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
return this.bookingsService.findById(bookingId);
@@ -441,6 +465,7 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
+ this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts
index 88a4ec725..4752b004f 100644
--- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts
@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
+import { ContractNotifierService } from './contract-notifier.service';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
@@ -26,6 +27,7 @@ export class BookingRequestService {
private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
+ private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
@@ -107,7 +109,7 @@ export class BookingRequestService {
};
const reference = await this.generateReference();
- return this.repo.create({
+ const request = await this.repo.create({
reference,
contractId,
requestedByUserId: userId ?? null,
@@ -117,6 +119,8 @@ export class BookingRequestService {
requestedLines,
notes: dto.notes ?? null,
} as never);
+ this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
+ return request;
}
listForContract(contractId: string): Promise {
diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts
index e033f8e9b..4a58e50be 100644
--- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts
@@ -6,6 +6,7 @@ import {
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
+import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
}
async listForBooking(bookingId: string): Promise {
- return this.repo.find({
+ const rows = await this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
+
+ // Self-heal: a booking that has settled its freight payment must have
+ // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
+ // export FCFS booking (linked to its train at booking time) paid via the
+ // prepaid invoice can leave the milestone PENDING — the clearance "Payment &
+ // wagon allocation" step then never ticks. getClearanceView backfills it, but
+ // the stepper reads its gating milestones straight from here, so heal here too.
+ // Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
+ const paymentSettled = rows.find(
+ (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
+ );
+ if (paymentSettled && paymentSettled.status === 'PENDING') {
+ const booking = await this.dataSource.getRepository(Booking).findOne({
+ where: { id: bookingId },
+ select: { id: true, status: true, paymentStatus: true },
+ });
+ if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
+ await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
+ return this.repo.find({
+ where: { bookingId },
+ order: { sortOrder: 'ASC' },
+ });
+ }
+ }
+
+ return rows;
}
/**
diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts
index f30b64597..75178d640 100644
--- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts
@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
contractsRepository as never,
milestoneService as never,
bookingsRepository as never,
+ { clearanceReady: jest.fn() } as never, // notifier
);
return { service, milestoneService, contractsRepository, bookingsRepository };
}
diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts
index 758da27ff..9b17a3e76 100644
--- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts
@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
+import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
+ private readonly notifier: BookingLifecycleNotifierService,
) {}
boundaryMilestone(tradeDirection: string): string {
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
+ // Tell the customer clearance is done and operation can be requested. Load
+ // failure only skips the notice — the status change above already committed.
+ try {
+ const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
+ if (booking) this.notifier.clearanceReady(booking);
+ } catch {
+ /* notification is best-effort */
+ }
}
resolvePhase(
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index 873daaf15..5e80b1301 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
+ // Intercity (DOMESTIC) bookings ride on a passing import/export train:
+ // there is no window and no date — staff accept them onto a train at
+ // finalize time, so both the window gate and scheduledDate are skipped.
+ const isIntercity = contract.tradeDirection === 'DOMESTIC';
+ if (isIntercity && dto.scheduledDate) {
+ throw new BadRequestException(
+ 'Intercity bookings do not pick a date — staff assign them to a passing train',
+ );
+ }
+ // Every other direction keeps the binding shipment day (the DTO field went
+ // optional only for intercity).
+ if (!isIntercity && !dto.scheduledDate) {
+ throw new BadRequestException('A binding shipment day is required');
+ }
+
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
- if (!generalCustoms) {
+ if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
index 2c79ba42f..3d41e68a5 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
+import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
@@ -118,6 +119,7 @@ export class ContractClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
+ private readonly notifier: ContractNotifierService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -543,7 +545,9 @@ export class ContractClearanceService {
await this.workflowService.onDocumentReviewReopened(contractId);
}
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.clearanceDocsUploadedToStaff(updated);
+ return updated;
}
private async assertRequiredInputsPresent(
@@ -674,6 +678,7 @@ export class ContractClearanceService {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
+ this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
},
userId,
);
+ this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
}
return this.contractsService.findById(contractId);
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
});
}
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.dutySlipUploadedToStaff(updated);
+ return updated;
}
async uploadTransitPermit(
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
await this.workflowService.markReadyForBooking(contractId);
}
+ this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
new file mode 100644
index 000000000..d4f31e570
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
@@ -0,0 +1,245 @@
+import { Injectable, Logger } from '@nestjs/common';
+import {
+ NotificationAudience,
+ NotificationType,
+ NotifyInput,
+} from '@edr/types';
+
+import { Contract } from './entities/contract.entity';
+import { NotificationsService } from '../notifications/notifications.service';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
+
+/**
+ * Customer + staff notifications for the contract lifecycle. Every customer
+ * event fans out over three channels: SMS + email (direct, via
+ * {@link NotificationsService}) and a persisted in-app notification (via
+ * {@link NotificationInboxService}) that deep-links to the contract detail page.
+ * Staff events go to the backoffice inbox. All sends are fire-and-forget and
+ * never throw — a notification failure must not break a contract transition.
+ */
+@Injectable()
+export class ContractNotifierService {
+ private readonly logger = new Logger(ContractNotifierService.name);
+
+ constructor(
+ private readonly notifications: NotificationsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
+
+ private ref(c: Contract): string {
+ return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
+ }
+
+ /** Send SMS + email to the contract's company contact; log-only on failure. */
+ private async notifyContact(
+ c: Contract,
+ message: string,
+ logLabel: string,
+ ): Promise {
+ this.logger.log(`${logLabel} — ${this.ref(c)}`);
+ const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
+ const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
+
+ if (phone) {
+ try {
+ await this.notifications.directSend('sms', phone, message);
+ } catch (err) {
+ this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
+ }
+ }
+ if (email) {
+ try {
+ await this.notifications.directSend('email', email, message);
+ } catch (err) {
+ this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
+ }
+ }
+ if (!phone && !email) {
+ this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
+ }
+ }
+
+ /** Persist + push an in-app item to all portal users of the contract's company. */
+ private inApp(
+ c: Contract,
+ title: string,
+ body: string,
+ overrides: Partial = {},
+ ): void {
+ if (!c.companyId) return; // government/unlinked contracts have no portal users
+ void this.inbox.notify({
+ recipients: { companyId: c.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.CONTRACT_STATUS,
+ title,
+ body,
+ link: `/contracts/${c.id}`,
+ data: { contractId: c.id, reference: c.reference },
+ ...overrides,
+ });
+ }
+
+ /** Persist + push an in-app item to every backoffice staff user. */
+ private inAppStaff(
+ c: Contract,
+ title: string,
+ body: string,
+ overrides: Partial = {},
+ ): void {
+ void this.inbox.notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title,
+ body,
+ link: `/dashboard/contract-requests/${c.id}`,
+ data: { contractId: c.id, reference: c.reference },
+ ...overrides,
+ });
+ }
+
+ // ── Customer-facing lifecycle events ───────────────────────────────────────
+
+ /** Line staff accepted intake → contract is under approval. */
+ accepted(c: Contract): void {
+ const msg =
+ `Your contract ${c.reference} has been accepted and is now under approval. ` +
+ `We will notify you once it is approved.`;
+ void this.notifyContact(c, msg, 'ACCEPTED');
+ this.inApp(c, 'Contract accepted', msg);
+ }
+
+ /** All approval steps complete → contract approved. */
+ approved(c: Contract): void {
+ const msg =
+ `Your contract ${c.reference} has been approved. ` +
+ `The final document will be prepared for signing.`;
+ void this.notifyContact(c, msg, 'APPROVED');
+ this.inApp(c, 'Contract approved', msg);
+ }
+
+ /** Fully executed (all parties signed) → contract active, customer can book. */
+ signedActive(c: Contract): void {
+ const msg =
+ `Your contract ${c.reference} has been signed and is now active. ` +
+ `You can start booking shipments from the portal.`;
+ void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
+ this.inApp(c, 'Contract active', msg);
+ }
+
+ /** Staff rejected the contract. */
+ rejected(c: Contract, reason: string): void {
+ const msg =
+ `Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
+ `Please contact us for details.`;
+ void this.notifyContact(c, msg, 'REJECTED');
+ this.inApp(c, 'Contract rejected', msg);
+ }
+
+ /** Staff requested changes before approval. */
+ changesRequested(c: Contract, note: string): void {
+ const msg =
+ `Changes were requested on your contract ${c.reference}: ${note}. ` +
+ `Please update and resubmit from the portal.`;
+ void this.notifyContact(c, msg, 'CHANGES REQUESTED');
+ this.inApp(c, 'Contract changes requested', msg);
+ }
+
+ // ── Clearance milestones needing customer action ──────────────────────────
+
+ /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
+ dutyAdvised(c: Contract, amount: number, currency: string): void {
+ const msg =
+ `Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
+ `Please pay and upload the payment slip from the portal.`;
+ void this.notifyContact(c, msg, 'DUTY ADVISED');
+ this.inApp(c, 'Duty & tax advised', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ link: `/contracts/${c.id}/clearance`,
+ });
+ }
+
+ /** A clearance document was queried — customer must re-upload it. */
+ clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
+ const msg =
+ `A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
+ `${note}. Please re-upload from the portal.`;
+ void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
+ this.inApp(c, 'Clearance document queried', msg, {
+ type: NotificationType.DOCUMENT_ACTION,
+ link: `/contracts/${c.id}/clearance`,
+ });
+ }
+
+ /** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
+ preClearanceFinalized(c: Contract): void {
+ const msg =
+ `Pre-clearance for contract ${c.reference} is complete. ` +
+ `Your shipment is proceeding to document collection in Djibouti.`;
+ void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
+ this.inApp(c, 'Pre-clearance complete', msg, {
+ type: NotificationType.CLEARANCE_DECISION,
+ link: `/contracts/${c.id}/clearance`,
+ });
+ }
+
+ // ── Staff-facing (backoffice inbox) ────────────────────────────────────────
+
+ /** Customer submitted a contract for review. */
+ submittedToStaff(c: Contract): void {
+ this.inAppStaff(
+ c,
+ 'New contract submitted',
+ `Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
+ );
+ }
+
+ /** Customer signed the contract — staff counter-sign is next. */
+ customerSignedToStaff(c: Contract): void {
+ this.inAppStaff(
+ c,
+ 'Customer signed contract',
+ `Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
+ { link: `/dashboard/contract-requests/${c.id}/view` },
+ );
+ }
+
+ /** Customer uploaded clearance documents — GL review is next. */
+ clearanceDocsUploadedToStaff(c: Contract): void {
+ this.inAppStaff(
+ c,
+ 'Clearance documents uploaded',
+ `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
+ {
+ type: NotificationType.CLEARANCE_REVIEW,
+ link: `/dashboard/contracts/clearance/${c.id}`,
+ },
+ );
+ }
+
+ /** Customer uploaded the duty/tax payment slip — GL verifies it. */
+ dutySlipUploadedToStaff(c: Contract): void {
+ this.inAppStaff(
+ c,
+ 'Duty slip uploaded',
+ `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
+ {
+ type: NotificationType.PAYMENT_RECEIVED,
+ link: `/dashboard/contracts/clearance/${c.id}`,
+ },
+ );
+ }
+
+ /** Customer filed a shipment request under a GENERAL customs contract. */
+ shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
+ this.inAppStaff(
+ c,
+ 'New shipment request',
+ `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
+ {
+ link: `/dashboard/shipment-requests/${requestId}`,
+ data: { contractId: c.id, requestId, reference: requestRef },
+ },
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index e31392666..9cf06c905 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service';
+import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
@@ -66,6 +67,7 @@ export class ContractTransitionService {
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
private readonly otpService: OtpService,
+ private readonly notifier: ContractNotifierService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -79,7 +81,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.submittedToStaff(updated);
+ return updated;
}
/** Confirm a price change before submit (mirrors booking confirm-submit). */
@@ -93,7 +97,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.submittedToStaff(updated);
+ return updated;
}
/**
@@ -130,7 +136,9 @@ export class ContractTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.accepted(updated);
+ return updated;
}
/**
@@ -218,7 +226,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED',
} as never);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.changesRequested(updated, note);
+ return updated;
}
async reject(contractId: string, reason: string, actorId: string): Promise {
@@ -235,7 +245,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.rejected(updated, reason);
+ return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
@@ -297,7 +309,11 @@ export class ContractTransitionService {
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
}
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ if (allDone) {
+ this.notifier.approved(updated);
+ }
+ return updated;
}
/**
@@ -535,7 +551,9 @@ export class ContractTransitionService {
customerSignedAt: new Date(),
} as never);
await this.regenerateContractPdf(contractId, contract.reference);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.customerSignedToStaff(updated);
+ return updated;
}
return this.counterSign(contractId, dto, options);
@@ -605,7 +623,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
- return this.contractsService.findById(contractId);
+ const updated = await this.contractsService.findById(contractId);
+ this.notifier.signedActive(updated);
+ return updated;
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index a22c7cad4..8591a54d8 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -13,10 +13,12 @@ import {
UnauthorizedException,
UploadedFiles,
UploadedFile,
+ UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
@@ -242,6 +244,7 @@ export class ContractsController {
}
@Get('list-summary')
+ @BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
@ApiOkResponse({ type: ContractListSummaryDto })
findListSummary(@Query() filter: FilterContractDto) {
@@ -449,14 +452,25 @@ export class ContractsController {
}
@Post(':id/contract/sign')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
- @CurrentUser() user: AuthUserPayload,
+ @CurrentUser() user: TCurrentUser,
) {
+ // Each staff signing role maps to the permission that step already requires;
+ // customers sign their own contract with no permission key.
+ const signRolePermission: Record = {
+ STAFF: FREIGHT_PERMS.contracts.signStaff,
+ DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
+ CEO: FREIGHT_PERMS.contracts.approveCeo,
+ };
+ if (dto.role !== 'CUSTOMER') {
+ assertFreightPermission(user, signRolePermission[dto.role]);
+ }
return this.transitionService.sign(id, dto, {
- signerUserId: user?.id ?? user?.sub,
+ signerUserId: user?.id,
});
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
index a9f9dcf5d..33a547a9f 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
+import { NotificationsModule } from '../notifications/notifications.module';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
+import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
MinioModule,
SignaturesModule,
OtpModule,
+ NotificationsModule,
+ NotificationInboxModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
+ ContractNotifierService,
ContractTransitionService,
ContractClearanceService,
ClearanceWorkflowService,
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
index 73360f7f5..aaf064bff 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts
@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
+import { YardCountry } from '@edr/types';
+
+import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
+import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
}
}
+ /**
+ * Every route must match the contract's declared trade direction as derived
+ * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
+ * intercity). Intercity is Ethiopian-domestic only: both yards must be in
+ * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
+ * (e.g. an export lane on an import contract) are rejected for every kind.
+ */
+ private async assertRoutesMatchDirection(
+ tradeDirection: string,
+ routes: CreateContractDto['routes'],
+ ): Promise {
+ const yardIds = [
+ ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
+ ];
+ const yards = await this.dataSource
+ .getRepository(Yard)
+ .find({ where: yardIds.map((id) => ({ id })) });
+ const yardById = new Map(yards.map((y) => [y.id, y]));
+
+ for (const route of routes) {
+ const origin = yardById.get(route.originYardId);
+ const destination = yardById.get(route.destinationYardId);
+ if (!origin || !destination) {
+ throw new BadRequestException('Route references a yard that does not exist');
+ }
+ const derived = deriveTradeDirection(origin, destination);
+ if (derived !== tradeDirection) {
+ throw new BadRequestException(
+ `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
+ );
+ }
+ if (
+ derived === 'DOMESTIC' &&
+ (origin.country !== YardCountry.ETHIOPIA ||
+ destination.country !== YardCountry.ETHIOPIA)
+ ) {
+ throw new BadRequestException(
+ `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
+ );
+ }
+ }
+ }
+
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
+ await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
+ // Intercity never crosses a border, so a customs-including service type is
+ // a contradiction — the wizard hides them, the API enforces it.
+ if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
+ throw new BadRequestException(
+ 'Intercity contracts cannot use a service type that includes customs clearing',
+ );
+ }
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
+ if (dto.routes) {
+ await this.assertRoutesMatchDirection(
+ dto.tradeDirection ?? existing.tradeDirection,
+ dto.routes,
+ );
+ }
const updates: Record = {
contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
+ if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
+ throw new BadRequestException(
+ 'Intercity contracts cannot use a service type that includes customs clearing',
+ );
+ }
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
@@ -501,6 +567,21 @@ export class ContractsService {
);
}
+ // Surface the staff "request changes" note so the portal can show the
+ // customer what to fix. Degrade to null on lookup failure — a missing note
+ // must never 500 a contract fetch.
+ if (contract.status === 'CHANGES_REQUESTED') {
+ try {
+ const note = await this.contractsRepository.findLatestReviewNote(
+ contract.id,
+ 'CHANGES_REQUESTED',
+ );
+ contract.latestChangeRequestNote = note?.body ?? null;
+ } catch {
+ contract.latestChangeRequestNote = null;
+ }
+ }
+
return contract;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index ac404c9df..e3130da95 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID()
contractRouteId?: string;
- @ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
+ @ApiPropertyOptional({
+ description:
+ 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
+ example: '2026-07-15',
+ })
+ @IsOptional()
@IsDateString()
- scheduledDate!: string;
+ scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
index 07d08d3c0..0b0fab41b 100644
--- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
+++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
+
+ /**
+ * Body of the most recent CHANGES_REQUESTED review note, attached by
+ * ContractsService.findById so the portal can show the customer what staff
+ * asked them to fix. Lives in contract_review_notes, not a column here.
+ */
+ latestChangeRequestNote?: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
index 72e2d14d9..181d8b688 100644
--- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
+import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import {
@@ -53,6 +54,7 @@ export class GlOperationsService {
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService,
+ private readonly notifier: BookingLifecycleNotifierService,
) {}
private get bookings() {
@@ -64,7 +66,11 @@ export class GlOperationsService {
}
private async getBooking(bookingId: string): Promise {
- const booking = await this.bookings.findOne({ where: { id: bookingId } });
+ // company is loaded so customer notifications have a phone/email to target.
+ const booking = await this.bookings.findOne({
+ where: { id: bookingId },
+ relations: { company: true },
+ });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
@@ -447,6 +453,7 @@ export class GlOperationsService {
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice could not be created.');
+ this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
return summary;
}
@@ -455,7 +462,7 @@ export class GlOperationsService {
bookingId: string,
file: Express.Multer.File,
): Promise<{ uploaded: boolean }> {
- await this.getBooking(bookingId);
+ const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
const invoice = await this.billingService.findInvoice(
@@ -482,6 +489,7 @@ export class GlOperationsService {
code: 'final_invoice_slip',
file,
});
+ this.notifier.dutySlipUploadedToStaff(booking, 'final');
return { uploaded: true };
}
@@ -490,7 +498,7 @@ export class GlOperationsService {
bookingId: string,
userId?: string,
): Promise {
- await this.getBooking(bookingId);
+ const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
@@ -507,6 +515,7 @@ export class GlOperationsService {
);
}
await this.billingService.markInvoiceAsPaid(invoice.id);
+ this.notifier.finalInvoicePaid(booking);
}
void userId;
@@ -575,6 +584,7 @@ export class GlOperationsService {
},
userId,
);
+ this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
return { advised: true, skipped: false };
}
@@ -605,6 +615,7 @@ export class GlOperationsService {
booking.tradeDirection ?? 'IMPORT',
);
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
+ this.notifier.dutySlipUploadedToStaff(booking, 'second');
return { milestoneCompleted: true };
}
diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts
index 265e19303..be7964a3d 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts
@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
}
}
+ if (recipients.allBackoffice) {
+ try {
+ for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
+ ids.add(uid);
+ }
+ } catch (err) {
+ this.logger.warn(
+ `Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
+ );
+ }
+ }
+
return [...ids];
}
}
diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts
index 9c25778dc..a79a54503 100644
--- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts
+++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts
@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
+import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus;
+ /**
+ * Trade direction frozen from the yard countries at create/update
+ * (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
+ * Consumers (scheduling, booking windows) read this instead of re-deriving.
+ */
+ @Column({ name: 'direction', type: 'varchar', length: 10 })
+ direction!: ScheduleTradeDirection;
+
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}
diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts
index 937ffb0ae..34d007c63 100644
--- a/apps/edr-freight-api/src/modules/routes/routes.service.ts
+++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
+import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE',
+ direction: validated.direction,
}),
);
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
+ ...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
});
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different');
}
+ const originYardId = normalized[0].yardId;
+ const destinationYardId = normalized[normalized.length - 1].yardId;
+ const yardById = new Map(yards.map((yard) => [yard.id, yard]));
+ const direction = deriveTradeDirection(
+ yardById.get(originYardId) ?? { country: null },
+ yardById.get(destinationYardId) ?? { country: null },
+ );
+
return {
- originYardId: normalized[0].yardId,
- destinationYardId: normalized[normalized.length - 1].yardId,
+ originYardId,
+ destinationYardId,
+ direction,
milestones: normalized,
};
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts
index 38f2bc58b..53583e3e8 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts
@@ -1,5 +1,6 @@
+import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
+import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100)
label!: string;
- @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
- @IsString()
- @MaxLength(50)
- country!: string;
+ @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
+ @IsEnum(YardCountry)
+ country!: YardCountry;
@ApiPropertyOptional({ default: true })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts
index 249aa1847..3f7f1ae97 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts
@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
+import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
+ // Constrained to YardCountry by DTO validation + a DB CHECK constraint;
+ // route/schedule trade direction is derived from this value. Typed as the
+ // enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 })
- country!: string;
+ country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts
index 905e827e8..289e502f1 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts
@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
+ {
+ rescheduled: jest.fn(),
+ removedFromTrain: jest.fn(),
+ maintenanceMoved: jest.fn(),
+ } as never, // notifier
);
});
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
index dd20b100d..a9a3ae246 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
+import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
+ private readonly notifier: BookingNotifierService,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
displacedBookingIds: dto.displacedBookingIds,
});
+ // Notify affected customers (SMS + email). Best-effort — a notification
+ // failure must never fail the reschedule, so each send is fire-and-forget
+ // inside the notifier. Government pre-empt already notifies via the batch
+ // displaced() path, so skip removed-from-train notices for that trigger.
+ // Use the new departure date when the reschedule moved it (the in-memory
+ // `schedule` still holds the pre-update date).
+ const effectiveDeparture = dto.newDepartureDate
+ ? new Date(dto.newDepartureDate)
+ : schedule.scheduledDepartureDate;
+ await this.notifyRescheduleOutcome(dto, effectiveDeparture);
+
return { plan, schedule: assignResult };
}
+ /**
+ * Fan out reschedule notifications: bookings that stayed on the train hear the
+ * new departure date; bookings dropped off the train (staff reschedule, not a
+ * government pre-empt) hear they were removed. Loads each booking with its
+ * company so the notifier has a phone/email to reach.
+ */
+ private async notifyRescheduleOutcome(
+ dto: ExecuteRescheduleDto,
+ newDeparture: Date | null,
+ ): Promise {
+ const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
+ const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
+
+ if (newDeparture) {
+ for (const bookingId of dto.finalBookingIds) {
+ const booking = await this.loadBookingForNotify(bookingId);
+ if (!booking) continue;
+ if (isMaintenance) {
+ this.notifier.maintenanceMoved(booking, newDeparture);
+ } else {
+ this.notifier.rescheduled(booking, newDeparture);
+ }
+ }
+ }
+
+ // Government pre-empt displacements are already announced by the batch
+ // displaced() notice — don't double-notify. Staff reschedules are not.
+ if (!isGovPreempt) {
+ for (const bookingId of dto.displacedBookingIds) {
+ const booking = await this.loadBookingForNotify(bookingId);
+ if (!booking) continue;
+ this.notifier.removedFromTrain(booking);
+ }
+ }
+ }
+
+ private async loadBookingForNotify(bookingId: string): Promise {
+ try {
+ return await this.bookingsRepository.findByIdWithFiles(bookingId);
+ } catch {
+ return null;
+ }
+ }
+
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
index 4e1770dfb..ba0995679 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(withEarly?.window?.label).toContain('08:00');
});
});
+
+// Regression: a schedule created INSIDE its own window day must open right away
+// when the desk is open, and re-deriving after a settings change (close hour
+// extended past "now", or lead pulled so the window day becomes today) must
+// yield an immediate open — not tomorrow morning.
+describe('computeImportWindowTimes — immediate open inside the window day', () => {
+ // 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
+ const now = new Date('2026-07-06T16:15:17.000Z');
+ // Departs Thu 9 Jul ~08:53 EAT
+ const departure = new Date('2026-07-09T05:53:00.000Z');
+ const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
+
+ it('desk 8–23, created 19:15 on the window day → opens NOW', () => {
+ const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
+ expect(t.windowOpensAt.getTime()).toBe(now.getTime());
+ });
+
+ it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
+ const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
+ expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
+ });
+
+ it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
+ // Same call restampPendingWindows makes after the global-rules edit.
+ const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
+ expect(t.windowOpensAt.getTime()).toBe(now.getTime());
+ });
+
+ it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
+ const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
+ const t = computeImportWindowTimes(
+ departsJul10,
+ { ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
+ now,
+ );
+ expect(t.windowOpensAt.getTime()).toBe(now.getTime());
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
index dfbcfcb6d..0fdacc572 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
-/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
+/**
+ * Export booking window: a single FCFS window from `exportBookingLeadHours`
+ * before departure until departure. The open honours the daily desk hours —
+ * when the raw lead instant lands while the desk is shut, the window opens at
+ * the next desk opening instead (capped at departure, so a config whose desk
+ * never opens before the train leaves yields a zero-length window rather than
+ * one that outlives the train).
+ */
export function computeExportWindowTimes(
departure: Date,
- cfg: { exportBookingLeadHours: number },
+ cfg: {
+ exportBookingLeadHours: number;
+ windowOpenHour: number;
+ windowCloseHour: number;
+ },
): InitialWindowTimes {
- return {
- windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
- windowClosesAt: departure,
- };
+ const rawOpen = new Date(
+ departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
+ );
+ let opensAt = officeHoursOpen(rawOpen, {
+ windowOpenHour: cfg.windowOpenHour,
+ windowCloseHour: cfg.windowCloseHour,
+ });
+ if (opensAt.getTime() > departure.getTime()) {
+ opensAt = departure;
+ }
+ return { windowOpensAt: opensAt, windowClosesAt: departure };
}
/**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
- * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
+ * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
+ * with the open shifted to the next desk opening when it lands outside office hours
+ * (same math as `computeExportWindowTimes`).
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] {
if (direction === 'EXPORT') {
const start =
- anchorOpensAt ??
- new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
+ anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index 4282e045a..8f11cfc95 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
+ { emitPhase: jest.fn() } as never,
);
});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index e6af6c4a0..e2e339e9b 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -40,10 +40,11 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
+import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
-interface Capacity {
+export interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
+ private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
+ } else {
+ // Already linked at booking time (export FCFS: the customer books a
+ // specific train, so allocate() ran up front). allocate() is where the
+ // payment-settled tracking milestones are written, so on this branch we
+ // record them here — otherwise a paid, already-linked booking leaves
+ // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
+ void this.completeTrackingMilestones(bookingId, [
+ "WAGON_REQUESTED",
+ "FREIGHT_PAYMENT_PENDING",
+ "FREIGHT_PAYMENT_SETTLED",
+ ]);
+ void this.markWagonAllocatedMilestone(bookingId);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId);
}
+ // ---- intercity ride-along API ---------------------------------------------
+
+ /**
+ * Remaining capacity budget (wagons / weight / length) for a schedule, and
+ * the per-booking need calculator — exposed for the intercity accept flow,
+ * which reserves ride-along bookings onto import/export trains outside the
+ * batch engine.
+ */
+ async intercityCapacity(scheduleId: string): Promise<{
+ budget: Capacity;
+ needFor: (booking: Booking) => Capacity;
+ } | null> {
+ const schedule =
+ await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ const locomotive = schedule?.trainSet?.locomotive;
+ if (!schedule || !locomotive) return null;
+ const rules = await this.loadGlobalRules();
+ const wagonLengths = await this.loadWagonLengths();
+ const limits = await this.capacityLimits(locomotive, rules);
+ const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
+ return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
+ }
+
+ /**
+ * Accept an intercity booking onto the given train. Commercial bookings get
+ * the same pay-window lifecycle as a batch reservation (deadline, invoice
+ * due-date sync, pay-now notify, settle on the window tick), so payment →
+ * allocation needs no special path. Government bookings allocate directly.
+ */
+ async acceptIntercity(booking: Booking, scheduleId: string): Promise {
+ if (booking.isGovernment) {
+ await this.dataSource
+ .getRepository(Booking)
+ .update(booking.id, { trainScheduleId: scheduleId });
+ booking.trainScheduleId = scheduleId;
+ await this.allocate(scheduleId, booking, 'gov');
+ return;
+ }
+ await this.reserve(booking, scheduleId);
+ this.armSettle(scheduleId);
+ }
+
// ---- mutations ------------------------------------------------------------
/**
@@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
+ // Customer tracking: a wagon slot is reserved and the freight pay window is
+ // open. Doc-trigger path — silent no-op for bookings without milestone rows.
+ void this.completeTrackingMilestones(booking.id, [
+ "WAGON_REQUESTED",
+ "FREIGHT_PAYMENT_PENDING",
+ ]);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit {
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
+ // Customer tracking: freight payment settled (commercial pay-window path).
+ // Government allocations don't pay upfront — theirs stay pending.
+ if (reason === 'paid') {
+ void this.completeTrackingMilestones(booking.id, [
+ 'WAGON_REQUESTED',
+ 'FREIGHT_PAYMENT_PENDING',
+ 'FREIGHT_PAYMENT_SETTLED',
+ ]);
+ }
}
private async markWagonAllocatedMilestone(bookingId: string): Promise {
@@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit {
}
}
+ /**
+ * Complete customer-tracking milestones on lifecycle events via the
+ * doc-trigger path — a silent no-op for bookings without milestone rows
+ * (non-customs bookings). Never blocks the batch action.
+ */
+ private async completeTrackingMilestones(
+ bookingId: string,
+ codes: string[],
+ ): Promise {
+ if (!this.milestoneService) return;
+ for (const code of codes) {
+ try {
+ await this.milestoneService.completeByDocTrigger({ bookingId }, code);
+ } catch (err) {
+ this.logger.warn(
+ `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
+ );
+ }
+ }
+ }
+
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
@@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
+ // Push the change (open / train full / closed) so portal home and GL cards
+ // flip in real time — FULL in particular happens outside the window tick
+ // (batch fill, staff mark-paid) and had no live signal before.
+ try {
+ const fresh = await this.trainSchedulesRepository.findById(scheduleId);
+ if (fresh) this.bookingWindowGateway.emitPhase(fresh);
+ } catch (err) {
+ this.logger.warn(
+ `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
+ );
+ }
}
/** No wagon slots left for allocated + reserved bookings. */
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
index 49df19758..f1f63802e 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts
@@ -1,13 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
+import {
+ NotificationAudience,
+ NotificationType,
+ NotifyInput,
+} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
+import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name);
- constructor(private readonly notifications: NotificationsService) {}
+ constructor(
+ private readonly notifications: NotificationsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
@@ -41,11 +51,34 @@ export class BookingNotifierService {
}
}
+ /** Persist + push an in-app item to all portal users of the booking's company. */
+ private inApp(
+ b: Booking,
+ title: string,
+ body: string,
+ overrides: Partial = {},
+ ): void {
+ if (!b.companyId) return; // government/unlinked bookings have no portal users
+ void this.inbox.notify({
+ recipients: { companyId: b.companyId },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.SCHEDULE_UPDATE,
+ title,
+ body,
+ link: `/bookings/${b.id}`,
+ data: { bookingId: b.id, reference: b.reference },
+ ...overrides,
+ });
+ }
+
async payNow(b: Booking, deadline: Date): Promise {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
+ this.inApp(b, 'Payment window open', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
}
/**
@@ -66,6 +99,9 @@ export class BookingNotifierService {
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
+ this.inApp(b, 'Partial allocation offer', msg, {
+ type: NotificationType.INVOICE_ISSUED,
+ });
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
@@ -73,11 +109,13 @@ export class BookingNotifierService {
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
+ this.inApp(b, 'Wagon allocated', msg);
}
expired(b: Booking): void {
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED');
+ this.inApp(b, 'Payment window expired', msg);
}
scheduleFull(b: Booking): void {
@@ -100,5 +138,42 @@ export class BookingNotifierService {
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
+ this.inApp(b, 'Booking displaced', msg);
+ }
+
+ /**
+ * Staff rescheduled the train carrying this booking to a new departure date.
+ * The booking stays on the train — only the date moved.
+ */
+ rescheduled(b: Booking, newDeparture: Date): void {
+ const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
+ const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
+ void this.notifyContact(b, msg, 'RESCHEDULED');
+ this.inApp(b, 'Booking rescheduled', msg);
+ }
+
+ /**
+ * Booking was removed from its train during a staff reschedule (not a government
+ * pre-empt). It returns to eligible — the customer must rebook or reschedule.
+ */
+ removedFromTrain(b: Booking): void {
+ const msg =
+ `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
+ `Please rebook or select a new schedule from the portal.`;
+ void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
+ this.inApp(b, 'Removed from train', msg);
+ }
+
+ /**
+ * The train carrying this booking was moved for maintenance to a new departure
+ * date. The booking stays on the train — only the date moved.
+ */
+ maintenanceMoved(b: Booking, newDeparture: Date): void {
+ const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
+ const msg =
+ `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
+ `New departure date: ${when}.`;
+ void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
+ this.inApp(b, 'Train maintenance reschedule', msg);
}
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts
new file mode 100644
index 000000000..a439e442d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts
@@ -0,0 +1,109 @@
+import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
+import { INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { io, type Socket } from 'socket.io-client';
+
+import { WsAuthService } from '../notification-inbox/ws-auth.service';
+import { BookingWindowGateway } from './booking-window.gateway';
+import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
+
+/**
+ * End-to-end proof the booking-window socket works: boots a real Nest app with
+ * the gateway, connects a real socket.io client to the namespace, emits a phase
+ * change, and asserts the client receives the exact payload. If this passes,
+ * any "no live update" report is environmental (stale server process, wrong
+ * checkout running, client not connecting) — not the gateway.
+ */
+describe('BookingWindowGateway (e2e)', () => {
+ let app: INestApplication;
+ let gateway: BookingWindowGateway;
+ let client: Socket;
+ let baseUrl: string;
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ BookingWindowGateway,
+ // Accept any token — auth plumbing is covered by the real WsAuthService.
+ { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
+ ],
+ }).compile();
+
+ app = moduleRef.createNestApplication();
+ await app.listen(0);
+ const address = app.getHttpServer().address() as { port: number };
+ baseUrl = `http://127.0.0.1:${address.port}`;
+ gateway = app.get(BookingWindowGateway);
+ });
+
+ afterAll(async () => {
+ client?.disconnect();
+ await app?.close();
+ });
+
+ it('authenticated client receives the phase event with the schedule state', async () => {
+ client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
+ auth: { token: 'any' },
+ transports: ['websocket'],
+ });
+ await new Promise((resolve, reject) => {
+ client.on('connect', () => resolve());
+ client.on('connect_error', (err) => reject(err));
+ });
+
+ const received = new Promise>((resolve) => {
+ client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
+ });
+
+ gateway.emitPhase({
+ id: 'sched-1',
+ originStationId: 'yard-a',
+ destinationStationId: 'yard-b',
+ direction: 'IMPORT',
+ windowPhase: 'OPEN',
+ bookingWindowStatus: 'OPEN',
+ bookingCycleNo: 2,
+ windowOpensAt: new Date('2026-07-06T16:15:00Z'),
+ windowClosesAt: new Date('2026-07-06T16:18:00Z'),
+ docReviewEndsAt: null,
+ paymentPhaseEndsAt: null,
+ scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
+ } as unknown as TrainSchedule);
+
+ const payload = await received;
+ expect(payload).toMatchObject({
+ scheduleId: 'sched-1',
+ phase: 'OPEN',
+ bookingWindowStatus: 'OPEN',
+ bookingCycleNo: 2,
+ windowOpensAt: '2026-07-06T16:15:00.000Z',
+ });
+ });
+
+ it('rejects a client whose token does not resolve to a user', async () => {
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ BookingWindowGateway,
+ { provide: WsAuthService, useValue: { resolveUserId: async () => null } },
+ ],
+ }).compile();
+ const rejectingApp = moduleRef.createNestApplication();
+ await rejectingApp.listen(0);
+ const addr = rejectingApp.getHttpServer().address() as { port: number };
+
+ const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
+ auth: { token: 'bad' },
+ transports: ['websocket'],
+ reconnection: false,
+ });
+ const outcome = await new Promise((resolve) => {
+ rejected.on('disconnect', () => resolve('disconnected'));
+ rejected.on('connect_error', () => resolve('rejected'));
+ // The server accepts the transport then drops it in handleConnection.
+ setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
+ });
+ rejected.disconnect();
+ await rejectingApp.close();
+ expect(outcome).not.toBe('still-connected');
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts
new file mode 100644
index 000000000..699471d90
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts
@@ -0,0 +1,80 @@
+import {
+ BOOKING_WINDOW_WS_EVENTS,
+ BOOKING_WINDOW_WS_NAMESPACE,
+ type BookingWindowPhaseEvent,
+} from '@edr/types';
+import { Logger } from '@nestjs/common';
+import {
+ OnGatewayConnection,
+ WebSocketGateway,
+ WebSocketServer,
+} from '@nestjs/websockets';
+import { Server, Socket } from 'socket.io';
+
+import { WsAuthService } from '../notification-inbox/ws-auth.service';
+import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
+
+/**
+ * Server → client push for booking-window state changes. Same handshake model
+ * as the notifications gateway: clients only listen, the token is verified on
+ * connect. Events are broadcast namespace-wide — window state is route-scoped
+ * public information for signed-in users, and clients filter/invalidate their
+ * own queries.
+ */
+@WebSocketGateway({
+ namespace: BOOKING_WINDOW_WS_NAMESPACE,
+ cors: { origin: true, credentials: true },
+})
+export class BookingWindowGateway implements OnGatewayConnection {
+ private readonly logger = new Logger(BookingWindowGateway.name);
+
+ @WebSocketServer()
+ private readonly server!: Server;
+
+ constructor(private readonly wsAuth: WsAuthService) {}
+
+ async handleConnection(socket: Socket): Promise {
+ const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
+ if (!userId) {
+ this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
+ socket.disconnect(true);
+ return;
+ }
+ socket.data.userId = userId;
+ // Log at info so "is anyone actually connected?" is answerable from the
+ // API log when diagnosing missing live updates.
+ this.logger.log(`Booking-window client connected (user ${userId})`);
+ }
+
+ /** Push a schedule's current window state to every connected client. */
+ emitPhase(schedule: TrainSchedule): void {
+ const payload: BookingWindowPhaseEvent = {
+ scheduleId: schedule.id,
+ originYardId: schedule.originStationId,
+ destinationYardId: schedule.destinationStationId,
+ direction: schedule.direction ?? null,
+ phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
+ bookingWindowStatus: schedule.bookingWindowStatus ?? null,
+ bookingCycleNo: schedule.bookingCycleNo,
+ windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
+ windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
+ docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
+ paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
+ scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
+ };
+ this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
+ }
+
+ private extractToken(socket: Socket): string | undefined {
+ const authToken = socket.handshake.auth?.token as string | undefined;
+ if (authToken) return authToken;
+
+ const queryToken = socket.handshake.query?.token;
+ if (typeof queryToken === 'string') return queryToken;
+
+ const header = socket.handshake.headers?.authorization;
+ if (header?.startsWith('Bearer ')) return header.slice(7);
+
+ return undefined;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
index 449c67219..429a03a05 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
@@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
-import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
+import {
+ NotificationAudience,
+ NotificationType,
+ TrainScheduleStatus as TrainScheduleStatusEnum,
+} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BookingBatchService } from './booking-batch.service';
+import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
+ private readonly inbox: NotificationInboxService,
+ private readonly gateway: BookingWindowGateway,
) {}
async onModuleInit(): Promise {
@@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit {
);
}
- @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
+ // 10-second cadence: every transition is derived from persisted timestamps
+ // and applied idempotently, so a finer tick only shrinks the lag between a
+ // deadline passing and the phase actually moving (was a full minute).
+ @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise {
if (this.ticking) return;
this.ticking = true;
@@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
- // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
+ // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
+ // (30 ticks at the 10-second cadence).
this.tickCount += 1;
- if (this.tickCount % 5 === 0) {
+ if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill();
}
} finally {
@@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now);
if (!advanced) return;
+ // Push the new window state to portal home / backoffice GL sections so
+ // they refresh instantly instead of waiting out their poll interval.
+ this.gateway.emitPhase(schedule);
}
}
@@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
- await this.notifyWindowOpened(schedule);
+ // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
+ // (the `ticking` guard would otherwise delay every schedule's transition).
+ void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
- if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
+ // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
+ if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
@@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise {
try {
- const rows: Array<{ phone: string | null; email: string | null }> =
- await this.dataSource.query(
- `SELECT DISTINCT
- COALESCE(co.contact_person_phone, co.phone) AS phone,
- COALESCE(co.email, co.general_manager_email) AS email
- FROM freight.contract_routes cr
- JOIN freight.contracts c
- ON c.id = cr.contract_id
- AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
- AND c.deleted_at IS NULL
- JOIN freight.companies co ON co.id = c.company_id
- WHERE cr.origin_yard_id = $1
- AND cr.destination_yard_id = $2
- AND cr.deleted_at IS NULL`,
- [schedule.originStationId, schedule.destinationStationId],
- );
+ const rows: Array<{
+ company_id: string;
+ phone: string | null;
+ email: string | null;
+ }> = await this.dataSource.query(
+ `SELECT DISTINCT
+ c.company_id,
+ COALESCE(co.contact_person_phone, co.phone) AS phone,
+ COALESCE(co.email, co.general_manager_email) AS email
+ FROM freight.contract_routes cr
+ JOIN freight.contracts c
+ ON c.id = cr.contract_id
+ AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
+ AND c.deleted_at IS NULL
+ JOIN freight.companies co ON co.id = c.company_id
+ WHERE cr.origin_yard_id = $1
+ AND cr.destination_yard_id = $2
+ AND cr.deleted_at IS NULL`,
+ [schedule.originStationId, schedule.destinationStationId],
+ );
if (!rows.length) return;
const closes = schedule.windowClosesAt
@@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
const seenPhone = new Set();
const seenEmail = new Set();
+ const seenCompany = new Set();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
@@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
+ // In-app inbox item for every portal user of each eligible company,
+ // deep-linking to the new-booking page.
+ if (r.company_id && !seenCompany.has(r.company_id)) {
+ seenCompany.add(r.company_id);
+ void this.inbox.notify({
+ recipients: { companyId: r.company_id },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.SCHEDULE_UPDATE,
+ title: 'Booking window open',
+ body: msg,
+ link: '/bookings/new',
+ data: { trainScheduleId: schedule.id },
+ });
+ }
}
this.logger.log(
- `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
+ `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts
new file mode 100644
index 000000000..bf1cec26d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts
@@ -0,0 +1,14 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
+
+export class AcceptIntercityBookingsDto {
+ @ApiProperty({
+ type: [String],
+ description:
+ 'Waiting intercity booking ids to accept onto this train, in priority order',
+ })
+ @IsArray()
+ @ArrayNotEmpty()
+ @IsUUID('4', { each: true })
+ bookingIds!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts
index 9232de29e..a0371178f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts
@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt()
@Min(0)
importWindowLeadDays?: number;
+
+ @ApiPropertyOptional({
+ example: 24,
+ description:
+ 'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ exportBookingLeadHours?: number;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
new file mode 100644
index 000000000..659eea456
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
@@ -0,0 +1,367 @@
+import {
+ BadRequestException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+import { DataSource } from 'typeorm';
+
+import { Booking } from '../bookings/entities/booking.entity';
+import { RouteMilestone } from '../routes/entities/route-milestone.entity';
+import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
+import { BookingBatchService, type Capacity } from './booking-batch.service';
+import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
+
+/**
+ * Intercity (DOMESTIC) ride-along: intercity bookings never get their own
+ * train — they ride a passing import/export schedule whose route milestones
+ * contain the booking's origin strictly before its destination.
+ *
+ * Flow: the customer books a corridor with no date; at finalize time staff see
+ * every waiting intercity booking whose corridor lies on the schedule's route,
+ * with its wagon/weight/length need against the train's remaining capacity;
+ * accepting reserves it (pay window → payment → allocation, same lifecycle as
+ * a batch reservation). Cargo is loaded manually when the train reaches the
+ * booking's origin yard and unloaded at its destination yard.
+ */
+@Injectable()
+export class IntercityService {
+ private readonly logger = new Logger(IntercityService.name);
+
+ constructor(
+ @InjectDataSource() private readonly dataSource: DataSource,
+ private readonly bookingBatchService: BookingBatchService,
+ ) {}
+
+ /**
+ * Waiting intercity bookings this schedule could carry, with the train's
+ * remaining capacity along all three axes (wagons, weight, length) and each
+ * booking's need, so staff can pick what fits.
+ */
+ async listCandidates(scheduleId: string) {
+ const schedule = await this.getSchedule(scheduleId);
+ const milestoneSeq = await this.routeMilestoneSequence(schedule);
+ const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
+
+ const waiting = milestoneSeq
+ ? await this.findWaitingIntercityBookings(milestoneSeq)
+ : [];
+ const accepted = await this.findAcceptedIntercityBookings(scheduleId);
+
+ return {
+ scheduleId,
+ routeId: schedule.routeId ?? null,
+ remaining: capacity?.budget ?? null,
+ candidates: waiting.map((booking) => {
+ const need = capacity?.needFor(booking) ?? null;
+ return {
+ ...this.mapBooking(booking),
+ need,
+ fits: need && capacity ? fits(need, capacity.budget) : false,
+ };
+ }),
+ accepted: accepted.map((booking) => ({
+ ...this.mapBooking(booking),
+ need: capacity?.needFor(booking) ?? null,
+ })),
+ };
+ }
+
+ /**
+ * Accept selected waiting intercity bookings onto this train, in the given
+ * order, each re-checked against the shrinking capacity budget. Commercial
+ * bookings open a pay window (payment → allocation runs on the existing
+ * settle lifecycle); government bookings allocate immediately.
+ */
+ async acceptBookings(scheduleId: string, bookingIds: string[]) {
+ if (bookingIds.length === 0) {
+ throw new BadRequestException('Select at least one intercity booking');
+ }
+ const schedule = await this.getSchedule(scheduleId);
+ const milestoneSeq = await this.routeMilestoneSequence(schedule);
+ if (!milestoneSeq) {
+ throw new BadRequestException(
+ 'Schedule has no route milestones — cannot serve intercity corridors',
+ );
+ }
+ const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
+ if (!capacity) {
+ throw new BadRequestException(
+ 'Schedule has no locomotive/train set — capacity unknown',
+ );
+ }
+
+ const accepted: string[] = [];
+ const rejected: Array<{ bookingId: string; reason: string }> = [];
+ let budget = capacity.budget;
+
+ for (const bookingId of bookingIds) {
+ const booking = await this.dataSource
+ .getRepository(Booking)
+ .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
+ if (!booking) {
+ rejected.push({ bookingId, reason: 'Booking not found' });
+ continue;
+ }
+ const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
+ if (notWaiting) {
+ rejected.push({ bookingId, reason: notWaiting });
+ continue;
+ }
+ const need = capacity.needFor(booking);
+ if (!fits(need, budget)) {
+ rejected.push({
+ bookingId,
+ reason: 'Does not fit the remaining wagon/weight/length capacity',
+ });
+ continue;
+ }
+ await this.bookingBatchService.acceptIntercity(booking, scheduleId);
+ budget = subtract(budget, need);
+ accepted.push(bookingId);
+ this.logger.log(
+ `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
+ );
+ }
+
+ return { accepted, rejected, remaining: budget };
+ }
+
+ /**
+ * Mark an accepted intercity booking's cargo as loaded. Only allowed while
+ * the train is physically at the booking's origin yard: either it has not
+ * departed yet and the booking boards at the train's own origin, or the
+ * latest recorded checkpoint is at the booking's origin yard.
+ */
+ async loadBooking(scheduleId: string, bookingId: string) {
+ const { schedule, booking } = await this.getAcceptedBooking(
+ scheduleId,
+ bookingId,
+ );
+ if (booking.status !== 'PAID') {
+ throw new BadRequestException(
+ `Booking must be paid before loading (currently ${booking.status})`,
+ );
+ }
+ await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
+ await this.dataSource
+ .getRepository(Booking)
+ .update(bookingId, { status: 'IN_TRANSIT' });
+ return { bookingId, status: 'IN_TRANSIT' as const };
+ }
+
+ /**
+ * Mark an intercity booking's cargo as unloaded at its destination yard —
+ * requires the latest checkpoint to be at that yard. Completes the booking.
+ */
+ async unloadBooking(scheduleId: string, bookingId: string) {
+ const { schedule, booking } = await this.getAcceptedBooking(
+ scheduleId,
+ bookingId,
+ );
+ if (booking.status !== 'IN_TRANSIT') {
+ throw new BadRequestException(
+ `Booking must be loaded/in transit before unloading (currently ${booking.status})`,
+ );
+ }
+ await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
+ await this.dataSource
+ .getRepository(Booking)
+ .update(bookingId, { status: 'COMPLETED' });
+ return { bookingId, status: 'COMPLETED' as const };
+ }
+
+ // ---- helpers ---------------------------------------------------------------
+
+ private async getSchedule(scheduleId: string): Promise {
+ const schedule = await this.dataSource
+ .getRepository(TrainSchedule)
+ .findOne({ where: { id: scheduleId } });
+ if (!schedule) {
+ throw new NotFoundException(`Train schedule ${scheduleId} not found`);
+ }
+ return schedule;
+ }
+
+ /**
+ * yardId → sequenceNo for the schedule's route. Falls back to a two-stop
+ * origin/destination pseudo-route for legacy schedules without a routeId,
+ * so an intercity booking exactly matching the train's own corridor still
+ * qualifies.
+ */
+ private async routeMilestoneSequence(
+ schedule: TrainSchedule,
+ ): Promise |