diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 8ff24fd8e..84e3e4b7a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -96,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; +import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -265,6 +266,7 @@ if (!process.env.APPLICATION_NAME) { VerifaydaModule, EimsModule, FleetHistoryModule, + WagonHistoryModule, AiModule, AuditModule, ChatModule, diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts new file mode 100644 index 000000000..014df7491 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Unified per-wagon history ledger. One append-only row per transition + * (yard move, coupling, schedule pin/dispatch/release, status flip, cargo + * load/unload, container placement, lifecycle edits), written in the same + * transaction as the change. No foreign keys: history must survive the wagon, + * train, schedule or booking it points at. The two composite indexes back + * keyset pagination of a single wagon's timeline (optionally per category); + * the partial ones answer "what happened on this schedule / booking". + */ +export class WagonEvents3820000000000 implements MigrationInterface { + name = 'WagonEvents3820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL, + wagon_number varchar, + event_type varchar(40) NOT NULL, + category varchar(20) NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + actor_user_id uuid, + from_yard_id uuid, + to_yard_id uuid, + train_id uuid, + train_schedule_id uuid, + booking_id uuid, + from_value varchar(120), + to_value varchar(120), + reason text, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now() + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_time + ON freight.wagon_events (wagon_id, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_cat_time + ON freight.wagon_events (wagon_id, category, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_schedule + ON freight.wagon_events (train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_booking + ON freight.wagon_events (booking_id) + WHERE booking_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 06849d560..d29d52c42 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository"; import { PaymentModule } from "../payment/payment.module"; import { CompaniesModule } from "../companies/companies.module"; import { FilesModule } from "../files/files.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; @Module({ imports: [ @@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module"; DocumentsModule, UserTradeAccessModule, FilesModule, + // Customer notice when Finance confirms a manual payment. The inbox module + // reaches this one back through CompaniesModule, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index f07e6d50f..9a34516aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); }); @@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => { {} as never, { get: () => undefined } as never, { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager, savedLines }; } @@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, defaultManager, txManager, transaction }; }; @@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager }; }; @@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -959,6 +979,8 @@ describe("BillingService.document", () => { : undefined, } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, render, renderThermal }; }; @@ -1079,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { function makeService(invoiceType: string) { const invoice = { id: "inv-1", + invoiceNumber: "INV-001", + companyId: "company-1", source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: invoiceType, @@ -1089,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { const recordPayment = jest.fn().mockResolvedValue(invoice); const dataSource = { getRepository: () => ({ - findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + findOne: async () => ({ + id: "booking-1", + reference: "BK-001", + paymentDeadline: PAST, + }), }), + query: async () => [{ phone: "+251900000000", email: "c@x.com" }], }; + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); const service = new BillingService( dataSource as never, { findById: async () => invoice } as never, @@ -1103,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, { get: () => undefined } as never, { isEnabled: async () => true } as never, + { directSend } as never, + { notify } as never, ); (service as unknown as { recordPayment: unknown }).recordPayment = recordPayment; - return { service, recordPayment }; + return { service, recordPayment, directSend, notify }; } const slip = { originalname: "slip.pdf" } as never; @@ -1129,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { ); }); + it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => { + const { service, notify, directSend } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: { companyId: "company-1" }, + type: "PAYMENT_RECEIVED", + link: "/billing/inv-1", + body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/), + }), + ); + expect(directSend).toHaveBeenCalledWith( + "sms", + "+251900000000", + expect.stringContaining("INV-001"), + ); + expect(directSend).toHaveBeenCalledWith( + "email", + "c@x.com", + expect.stringContaining("INV-001"), + ); + }); + + it("still settles when the customer notice fails", async () => { + const { service, notify, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + notify.mockRejectedValueOnce(new Error("inbox down")); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).resolves.toBeDefined(); + expect(recordPayment).toHaveBeenCalled(); + }); + it("still requires the bank slip for a cancellation fee", async () => { const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); await expect( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index e9bdfb35f..ce8c95f40 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,9 @@ -import { Freight, PaymentReferenceType } from "@edr/types"; +import { + Freight, + NotificationAudience, + NotificationType, + PaymentReferenceType, +} from "@edr/types"; import { ConfigService } from "@nestjs/config"; import { BadRequestException, @@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { sendCompanyChannels } from "../notifications/notify-company.util"; +import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -273,7 +282,9 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, - ) {} + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -832,8 +843,9 @@ export class BillingService { uploadedByName: input.userName ?? null, }); - return this.recordPayment(invoiceId, { - amount: Number(invoice.balanceAmount), + const amount = Number(invoice.balanceAmount); + const paid = await this.recordPayment(invoiceId, { + amount, method: "BANK_TRANSFER", reference: input.reference || slip.name, metadata: { @@ -843,6 +855,104 @@ export class BillingService { confirmedByName: input.userName ?? null, }, }); + + // The customer did not pay through the portal, so nothing else tells them + // Finance has settled their invoice — this is their only confirmation. + await this.notifyCustomerManualPaymentConfirmed(paid, amount); + return paid; + } + + /** + * Tell the customer Finance confirmed their manual (bank transfer / counter) + * payment: portal inbox entry plus SMS and email to the company's contact + * (or the shipping line's own contact for a credit invoice). Best-effort — + * a notification failure never undoes the settlement, it is only logged. + */ + private async notifyCustomerManualPaymentConfirmed( + invoice: Invoice, + amount: number, + ): Promise { + try { + const bookingRef = + invoice.source === Freight.InvoiceSource.Booking + ? await this.bookingReferenceFor(invoice.sourceId) + : null; + const body = + `Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` + + (bookingRef ? ` (booking ${bookingRef})` : "") + + ` has been received and confirmed. Thank you.`; + const title = "Payment confirmed"; + const data = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + bookingId: bookingRef ? invoice.sourceId : null, + }; + + if (invoice.companyId || invoice.companyProfileId) { + await this.inbox.notify({ + recipients: invoice.companyId + ? { companyId: invoice.companyId } + : { companyProfileId: invoice.companyProfileId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/billing/${invoice.id}`, + data, + }); + if (invoice.companyId) { + await sendCompanyChannels( + this.dataSource, + this.notifications, + invoice.companyId, + body, + ); + } + return; + } + + if (invoice.shippingLineCompanyId) { + const target = await resolveShippingLineNotifyTarget( + this.dataSource, + invoice.shippingLineCompanyId, + ); + if (target.userId) { + await this.inbox.notify({ + recipients: { userIds: [target.userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/shipping-line/invoices/${invoice.id}`, + data, + }); + } + for (const [method, to] of [ + ["sms", target.phone], + ["email", target.email], + ] as const) { + if (!to) continue; + try { + await this.notifications.directSend(method, to, body); + } catch { + /* best-effort: provider unavailable */ + } + } + } + } catch (err) { + this.logger.warn( + `Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Booking reference for a booking id, or null when the booking is gone. */ + private async bookingReferenceFor(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: ["id", "reference"], + }); + return booking?.reference ?? null; } /** Invoice header plus its line items. */ 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 264ffb811..7324da975 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 @@ -370,7 +370,21 @@ export class BookingTransitionService { async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["PAID"]); + // Paid is read from the PAYMENT status only; the booking status merely + // guards against re-entering transit from a later stage. + if (booking.paymentStatus !== "PAID") { + throw new ConflictException( + `Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`, + ); + } + assertBookingStatus(booking, [ + "PAID", + "FULLY_EXECUTED", + "PNR_GENERATED", + "WAGON_ASSIGNED", + "READY_FOR_ASSIGNMENT", + "APPROVED", + ]); const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", @@ -1739,6 +1753,7 @@ export class BookingTransitionService { // (portal and backoffice). Degrades to null like every fragile field here. let trainSchedule: { trainNumber: string | null; + voyageNumber: string | null; reference: string | null; scheduledDepartureDate: Date | null; } | null = null; @@ -1750,6 +1765,8 @@ export class BookingTransitionService { if (s) { trainSchedule = { trainNumber: s.trainNumber ?? null, + // The schedule's own voyage (sailing) number shown to the customer. + voyageNumber: s.voyageNumber ?? null, reference: s.reference ?? null, scheduledDepartureDate: s.scheduledDepartureDate ?? null, }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index baed65bfc..4507e6479 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -53,6 +53,8 @@ import { CancelledUnitSnapshot, WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; export { WAGON_CANCEL_FEE_INVOICE_TYPE }; @@ -134,6 +136,7 @@ export class BookingWagonCancellationService { private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, + private readonly wagonHistory: WagonHistoryService, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -1847,6 +1850,7 @@ export class BookingWagonCancellationService { .getRepository(WagonAllocationContainerItem) .delete(cut.map((i) => i.id)); if (cut.length === items.length) { + await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(alloc.id); } else { const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); @@ -1893,9 +1897,66 @@ export class BookingWagonCancellationService { await manager .getRepository(WagonAllocationBulkLoad) .delete({ wagonBookingAllocationId: In(ids) }); + await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(ids); } + /** + * BOOKING_CANCELLED history row for every physical wagon behind the released + * allocations — resolved through the slot BEFORE the allocation rows go, one + * query for the whole batch. Slots with no wagon pinned yet leave no row. + */ + private async recordAllocationRelease( + manager: EntityManager, + allocationIds: string[], + bookingId: string, + reason: string, + ): Promise { + if (!allocationIds.length) return; + const rows: Array<{ + allocationId: string; + wagonId: string; + wagonNumber: string; + yardId: string | null; + trainId: string | null; + scheduleId: string | null; + weightTons: string | null; + loadType: string | null; + }> = await manager.query( + `SELECT a.id AS "allocationId", + w.id AS "wagonId", + w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", + w.train_id AS "trainId", + w.current_train_schedule_id AS "scheduleId", + a.allocated_weight_tons AS "weightTons", + a.load_type AS "loadType" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + await this.wagonHistory.record( + manager, + rows.map((r) => ({ + wagonId: r.wagonId, + wagonNumber: r.wagonNumber, + type: WagonEventType.BookingCancelled, + fromYardId: r.yardId, + trainId: r.trainId, + trainScheduleId: r.scheduleId, + bookingId, + reason, + metadata: { + allocationId: r.allocationId, + loadType: r.loadType, + weightTons: r.weightTons == null ? null : Number(r.weightTons), + }, + })), + ); + } + /** Pre-reduction quantities snapshot (only when the booking was never split before). */ private async currentQuantities( manager: EntityManager, 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 bcb461a14..ee7b218ea 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -665,9 +665,11 @@ export class BookingsController { @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); + // GL (createBooking) rebooks credits and must see the ledger for that. const staff = hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || - hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking); if (!staff) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, @@ -790,6 +792,14 @@ export class BookingsController { staffPermission: string, ): Promise { if (hasFreightPermission(user, staffPermission)) return; + // Rebooking a credit creates a booking under the contract — GL's booking + // creation key covers it even where the dedicated rebook key was never granted. + if ( + staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook && + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking) + ) { + return; + } const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); await this.bookingsService.assertCustomerCanAccessBooking( diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts index 2f9d984e6..61cdacd79 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { Container } from './entities/container.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @Injectable() export class ContainersService { @@ -19,6 +21,7 @@ export class ContainersService { @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} async create(dto: CreateContainerDto): Promise { @@ -150,7 +153,16 @@ export class ContainersService { // Placing a container on a wagon does not make it AVAILABLE. The status enum // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON // state, so leave the existing status unchanged rather than forcing AVAILABLE. - return containerRepo.save(container); + const saved = await containerRepo.save(container); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ContainerPlaced, + toYardId: wagon.currentYardId ?? null, + toValue: container.containerNumber, + metadata: { containerId: container.id, position }, + }); + return saved; }); } @@ -159,9 +171,23 @@ export class ContainersService { if (container.status === 'LOADED') { throw new ConflictException('Cannot unassign a loaded container'); } + const previousWagonId = container.wagonId; + const previousPosition = container.position ?? null; container.wagonId = null; container.position = null; container.status = 'AVAILABLE'; - return this.containerRepo.save(container); + const saved = await this.containerRepo.save(container); + if (previousWagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } }); + await this.wagonHistory.record(null, { + wagonId: previousWagonId, + wagonNumber: wagon?.wagonNumber ?? null, + type: WagonEventType.ContainerRemoved, + fromYardId: wagon?.currentYardId ?? null, + fromValue: container.containerNumber, + metadata: { containerId: container.id, position: previousPosition }, + }); + } + return saved; } } 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 7c41ee43a..23b3f164e 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 @@ -270,7 +270,7 @@ export class SchedulingRescheduleService { // M12: only announce a new departure when the date actually moved — // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. - await this.notifyRescheduleOutcome(dto, newDeparture); + await this.notifyRescheduleOutcome(scheduleId, dto, newDeparture); if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; @@ -283,6 +283,7 @@ export class SchedulingRescheduleService { * company so the notifier has a phone/email to reach. */ private async notifyRescheduleOutcome( + scheduleId: string, dto: ExecuteRescheduleDto, newDeparture: Date | null, ): Promise { @@ -294,9 +295,9 @@ export class SchedulingRescheduleService { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; if (isMaintenance) { - this.notifier.maintenanceMoved(booking, newDeparture); + this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason); } else { - this.notifier.rescheduled(booking, newDeparture); + this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason); } } } @@ -307,7 +308,8 @@ export class SchedulingRescheduleService { for (const bookingId of dto.displacedBookingIds) { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; - this.notifier.removedFromTrain(booking); + // Displaced bookings no longer point at the schedule — pass it explicitly. + this.notifier.removedFromTrain(booking, scheduleId); } } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts new file mode 100644 index 000000000..dfafcee43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts @@ -0,0 +1,32 @@ +import { trainRunLabel } from './train-run-label.util'; + +describe('trainRunLabel', () => { + it('names the departure by the schedule train number and voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe( + 'train 8001 (voyage V-117)', + ); + }); + + it('drops the voyage bracket when the schedule has no voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001'); + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001'); + }); + + it('still quotes the voyage when the pool train number is not assigned yet', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe( + 'train (voyage V-117)', + ); + }); + + it('returns null when neither number is known so callers can fall back', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull(); + expect(trainRunLabel(null)).toBeNull(); + expect(trainRunLabel(undefined)).toBeNull(); + }); + + it('capitalizes for sentence starts on request', () => { + expect( + trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }), + ).toBe('Train 8001 (voyage V-117)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts new file mode 100644 index 000000000..3c13506ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts @@ -0,0 +1,30 @@ +import { TrainSchedule } from './entities/train-schedule.entity'; + +export type TrainRunSource = Pick; + +/** + * How a departure is named in every customer-facing SMS / email: + * + * "train 8001 (voyage V-2026-117)" + * + * Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number` + * and `train_schedules.voyage_number`. The built train (`freight.trains`) carries + * a `train_name` that the build form labels "voyage number"; that is a different + * identifier and must never be quoted to customers. Always pass the schedule. + * + * Returns null when the schedule has neither number (older rows, or an unbuilt + * departure whose pool number is assigned at dispatch) so callers can fall back + * to a generic phrase instead of printing "train (voyage)". + */ +export function trainRunLabel( + schedule: TrainRunSource | null | undefined, + opts: { capitalize?: boolean } = {}, +): string | null { + if (!schedule) return null; + const train = schedule.trainNumber?.trim() || null; + const voyage = schedule.voyageNumber?.trim() || null; + if (!train && !voyage) return null; + const head = train ? `train ${train}` : 'train'; + const label = voyage ? `${head} (voyage ${voyage})` : head; + return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} 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 d9bae156a..c933a5e34 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 @@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit { const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || - booking.status === "PAID" || booking.paymentStatus === "PAID"; if (!isBatchPaid) return; @@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit { `SELECT id FROM freight.bookings WHERE deleted_at IS NULL AND train_schedule_id IS NULL - AND (payment_status = 'PAID' OR status = 'PAID') + AND payment_status = 'PAID' AND scheduled_date IS NOT NULL AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, [day], @@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit { schedule?.scheduledDepartureDate && eatDay(schedule.scheduledDepartureDate) !== previousDay ) { - this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule); } } @@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit { fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || - fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( @@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit { manager, ); }); - this.notifier.displaced(victim); + this.notifier.displaced(victim, scheduleId); budget.add(this.needFor(victim, wagonDims), victimLeg); // Displacing frees wagons the same way an expiry does — don't leave the // schedule stuck at FULL. @@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit { ).filter( (b) => b.paymentStatus === "PAID" || - b.status === "PAID" || !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); // Export FCFS: a customer's pending operation request HOLDS its wagons from @@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit { return reserved.some( (b) => b.paymentStatus !== "PAID" && - b.status !== "PAID" && b.paymentDeadline != null && !payWindowLapsed(b.paymentDeadline, now), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index ea936465e..91136ab97 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { { emit: jest.fn() } as never, // events {} as never, // notifications {} as never, // inbox + { record: jest.fn() } as never, // wagonHistory ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 46cbdb2f6..cc060bf4e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -31,12 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; -import { - notifyCarriageAcceptanceReady, - notifyLoadManifest, -} from '../notifications/notify-company.util'; +import { notifyCarriageAcceptanceReady,notifyLoadManifest } from '../notifications/notify-company.util'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + /** * Per-booking journey along a train's corridor — for EVERY trade direction. * @@ -65,12 +64,18 @@ export class BookingJourneyService { private readonly events: EventEmitter2, private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + private readonly wagonHistory: WagonHistoryService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + /** + * Whether a booking may be loaded. Paid is decided by the booking's + * PAYMENT status only — never by `status === 'PAID'`, which lags or is + * skipped on several flows (batch pay, manual mark-paid, gov expedite). + * Government bookings don't prepay: APPROVED is enough for them. + */ private canLoad(booking: Booking): boolean { - if (booking.status === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; return booking.isGovernment && booking.status === 'APPROVED'; } @@ -121,6 +126,7 @@ export class BookingJourneyService { loadedAt: now, loadedByUserId: userId ?? null, }); + await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null)); if (!booking.loadingStartedAt) { await manager .getRepository(Booking) @@ -192,7 +198,8 @@ export class BookingJourneyService { } if (!this.canLoad(booking)) { throw new BadRequestException( - `Booking must be paid before loading (currently ${booking.status})`, + `Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` + + `booking status ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); @@ -239,7 +246,12 @@ export class BookingJourneyService { if (booking.tradeDirection === 'DOMESTIC') { await this.autoPlaceOnFreedWagons(manager, schedule, booking); } - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. await manager @@ -346,6 +358,7 @@ export class BookingJourneyService { unloadedAt: now, unloadedByUserId: userId ?? null, }); + await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null)); const remaining = allocations.filter( (a) => a.id !== target.id && a.status !== 'DEPARTED', @@ -403,7 +416,12 @@ export class BookingJourneyService { arrivedAt: now, arrivedByUserId: userId ?? null, } as never); - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); // The facility took the cargo off the train — raise its GRN. Where the // facility also stores cargo (Indode), the event links the storage record @@ -482,6 +500,7 @@ export class BookingJourneyService { id: b.id, reference: b.reference, status: b.status, + paymentStatus: b.paymentStatus ?? null, tradeDirection: b.tradeDirection, isGovernment: b.isGovernment, customer: b.company?.name ?? 'Unknown customer', @@ -919,12 +938,61 @@ export class BookingJourneyService { scheduleId: string, bookingId: string, status: 'LOADED' | 'DEPARTED', + ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking }, ): Promise { const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId); if (!allocations.length) return; await manager .getRepository(WagonBookingAllocation) .update({ id: In(allocations.map((a) => a.id)) }, { status }); + if (!ctx) return; + // Per-wagon cargo history. Allocations already at (or past) the target + // status were logged by the per-wagon load/unload endpoint — skip them so + // the whole-booking completion never double-writes a wagon's row. + const pending = allocations.filter((a) => + status === 'LOADED' + ? a.status !== 'LOADED' && a.status !== 'DEPARTED' + : a.status !== 'DEPARTED', + ); + await this.wagonHistory.record( + manager, + pending + .map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId)) + .filter((e): e is WagonEventInput => e !== null), + ); + } + + /** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */ + private cargoEvent( + alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }, + schedule: TrainSchedule, + booking: Booking, + status: 'LOADED' | 'DEPARTED', + at: Date, + userId: string | null, + ): WagonEventInput | null { + const slot = alloc.trainSetWagon; + if (!slot?.physicalWagonId) return null; + const loaded = status === 'LOADED'; + return { + wagonId: slot.physicalWagonId, + wagonNumber: slot.physicalWagon?.wagonNumber ?? null, + type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded, + occurredAt: at, + actorUserId: userId, + toYardId: loaded + ? (slot.boardYardId ?? schedule.originStationId ?? null) + : (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null), + trainScheduleId: schedule.id, + trainId: schedule.trainSet?.trainId ?? null, + bookingId: booking.id, + toValue: booking.reference ?? null, + metadata: { + allocationId: alloc.id, + loadType: alloc.loadType ?? null, + weightTons: Number(alloc.allocatedWeightTons ?? 0), + }, + }; } private async allocationsForBooking( @@ -1012,6 +1080,20 @@ export class BookingJourneyService { ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, }); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: Freight.WagonEventType.ReleasedAtUnload, + occurredAt: now, + actorUserId: userId, + fromYardId: boardYardId ?? null, + toYardId: booking.destinationYardId ?? null, + trainScheduleId: schedule.id, + trainId: wagon.trainId ?? null, + bookingId: booking.id, + toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, + metadata: { slotId: slot.id }, + }); } } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts new file mode 100644 index 000000000..524eeddc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingNotifierService } from './booking-notifier.service'; + +/** + * Message wording for the schedule-related customer notices: every one must + * quote the SCHEDULE's train + voyage numbers, and reschedules must carry the + * staff-entered reason instead of a hard-coded "for maintenance". + */ +describe('BookingNotifierService messages', () => { + const schedule = { trainNumber: '8001', voyageNumber: 'V-117' }; + const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never; + const departure = new Date('2026-09-01T05:00:00.000Z'); + + let sent: string[]; + let inbox: string[]; + let service: BookingNotifierService; + + beforeEach(() => { + sent = []; + inbox = []; + const notifications = { + directSend: jest.fn(async (_m: string, _to: string, msg: string) => { + sent.push(msg); + }), + }; + const inboxSvc = { + notify: jest.fn(async (input: { body: string }) => { + inbox.push(input.body); + }), + }; + const trainSchedules = { + findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })), + }; + // Company contact lookup goes through raw SQL; return one phone + email. + const dataSource = { + query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]), + }; + service = new BookingNotifierService( + notifications as never, + inboxSvc as never, + trainSchedules as never, + dataSource as never, + ); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('maintenance reschedule quotes train, voyage and the staff reason', async () => { + service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.'); + await flush(); + expect(inbox[0]).toBe( + 'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('maintenance reschedule falls back to "for maintenance" without a reason', async () => { + service.maintenanceMoved(booking, departure, schedule, ' '); + await flush(); + expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date'); + }); + + it('plain reschedule carries the reason and the run label', async () => { + service.rescheduled(booking, departure, schedule, 'Crew change'); + await flush(); + expect(inbox[0]).toBe( + 'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('resolves the run label from a schedule id when only the id is known', async () => { + service.scheduleCancelled(booking, 'sched-1'); + await flush(); + expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/); + }); +}); 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 4600f7f38..fd42fdd7b 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 @@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util'; import { BATCH_TIMEZONE } from './booking-batch.constants'; +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + +/** + * " — reason: Locomotive maintenance" for the staff-entered reschedule reason, + * or '' when none was given. Trailing punctuation is trimmed so the sentence's + * own full stop follows cleanly. + */ +const reasonClause = (reason?: string | null): string => { + const text = reason?.trim().replace(/[.\s]+$/, ''); + return text ? ` — reason: ${text}` : ''; +}; + @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); @@ -30,8 +43,9 @@ export class BookingNotifierService { /** * Human-readable description of a train schedule for customer messages: - * reference (or train number) + route + departure date. Never leaks a UUID — - * falls back to a generic phrase when the schedule can't be loaded. + * train number + voyage number (both the SCHEDULE's own — see trainRunLabel), + * then reference, route and departure date. Never leaks a UUID — falls back + * to a generic phrase when the schedule can't be loaded. */ private async scheduleLabel(scheduleId?: string | null): Promise { const fallback = 'your selected train'; @@ -39,8 +53,10 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - // Customers know the train by its operating number (8001), not the - // schedule reference — lead with it and keep S-… as the secondary id. + // Customers know the departure by its train number (8001) and voyage + // number, not the schedule reference — lead with those and keep S-… as + // the secondary id. + const run = trainRunLabel(s); const parts = [ s.reference, s.originStation?.label && s.destinationStation?.label @@ -59,9 +75,9 @@ export class BookingNotifierService { hour12: false, })} EAT` : ''; - const number = s.trainNumber ?? s.reference ?? null; - return number - ? `train ${number}${number === s.reference ? '' : detail}${departure}` + if (run) return `${run}${detail}${departure}`; + return s.reference + ? `train ${s.reference}${departure}` : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( @@ -71,6 +87,51 @@ export class BookingNotifierService { } } + /** + * "train 8001 (voyage V-117)" for the departure a message is about, or null + * when nothing is known. Accepts the schedule row itself (preferred — callers + * that have just cancelled or detached the booking still hold it) or its id, + * falling back to the booking's own train_schedule_id. Never throws: a label + * lookup must not stop a notification going out. + */ + private async trainRun( + b: Booking, + schedule?: TrainRunSource | string | null, + ): Promise { + if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule); + const scheduleId = schedule ?? b.trainScheduleId ?? null; + if (!scheduleId) return null; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + return trainRunLabel(s); + } catch (err) { + this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`); + return null; + } + } + + /** + * Resolve the run label, then build and send the SMS/email + in-app item. + * Fire-and-forget like every notifier method; `build` receives the label + * (null when unknown) and returns the message text. + */ + private withRun( + b: Booking, + schedule: TrainRunSource | string | null | undefined, + logLabel: string, + title: string, + build: (run: string | null) => string, + opts: { contact?: boolean; inApp?: Partial } = {}, + ): void { + void (async () => { + const msg = build(await this.trainRun(b, schedule)); + if (opts.contact !== false) await this.notifyContact(b, msg, logLabel); + this.inApp(b, title, msg, opts.inApp); + })().catch((err) => + this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`), + ); + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -162,21 +223,30 @@ export class BookingNotifierService { } /** Train carrying the booking departed — dispatched origin → destination. */ - dispatched(b: Booking, origin: string | null, destination: string | null): void { - const msg = + dispatched( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) => `Your booking ${b.reference ?? b.id} has been dispatched` + - `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; - void this.notifyContact(b, msg, 'DISPATCHED'); - this.inApp(b, 'Shipment dispatched', msg); + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` + + `${run ? ` on ${run}` : ''}.`, + ); } /** Train carrying the booking arrived at destination. */ - arrived(b: Booking, origin: string | null, destination: string | null): void { - const msg = - `Your booking ${b.reference ?? b.id} has arrived` + - `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; - void this.notifyContact(b, msg, 'ARRIVED'); - this.inApp(b, 'Shipment arrived', msg); + arrived( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) => + `Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`, + ); } async payNow(b: Booking, deadline: Date): Promise { @@ -318,21 +388,28 @@ 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); + displaced(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) => + `Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` + + `Move to another schedule or cancel.`, + ); } /** * 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 { + rescheduled( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): 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); + this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) => + `Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` + + `${reasonClause(reason)}. New departure date: ${when}.`, + ); } /** @@ -340,49 +417,75 @@ export class BookingNotifierService { * the customer's original choice. In-app only — staff drove the change and * the allocation itself already notifies through the secured path. */ - allocatedOtherDay(b: Booking, newDeparture: Date): void { + allocatedOtherDay( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + - `New departure date: ${when}.`; - this.inApp(b, 'Booking allocated to another date', msg); + this.withRun( + b, + schedule, + 'ALLOCATED OTHER DAY', + 'Booking allocated to another date', + (run) => + `Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` + + `New departure date: ${when}.`, + { contact: false }, + ); } /** * 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); + removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) => + `Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`, + ); } /** * The train carrying this booking was cancelled. The booking is detached and * returns to the eligible pool — the customer must rebook or pick a new schedule. */ - scheduleCancelled(b: Booking): void { - const msg = - `The train for booking ${b.reference ?? b.id} has been cancelled. ` + - `Your booking is not lost — please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void { // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. - this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + this.withRun( + b, + schedule, + 'TRAIN CANCELLED', + 'Train cancelled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`, + { inApp: { priority: NotificationPriority.HIGH } }, + ); } /** - * The train carrying this booking was moved for maintenance to a new departure - * date. The booking stays on the train — only the date moved. + * The train carrying this booking was moved (maintenance reschedule) to a new + * departure date. The booking stays on the train — only the date moved. The + * staff-entered reason is what the customer reads; "for maintenance" is only + * the fallback when none was typed. */ - maintenanceMoved(b: Booking, newDeparture: Date): void { + maintenanceMoved( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): 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); + const why = reason?.trim() ? reasonClause(reason) : ' for maintenance'; + this.withRun( + b, + schedule, + 'MAINTENANCE RESCHEDULE', + 'Train rescheduled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` + + `New departure date: ${when}.`, + ); } } 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 47d0a0d21..49a730e68 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 @@ -11,6 +11,7 @@ import { 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 { trainRunLabel } from '../train-schedules/train-run-label.util'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { @@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + // Name the departure by the schedule's train + voyage numbers (never the + // built train's name) so customers can match it to yard/customs paperwork. + const run = trainRunLabel(schedule); const msg = - `Booking is now open for the train departing ${depart}. ` + + `Booking is now open for ${run ?? 'the train'} departing ${depart}. ` + `Book your shipment from the portal home page before ${closes} EAT.`; const seenPhone = new Set(); @@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + const run = trainRunLabel(schedule, { capitalize: true }); const msgFor = (corridors: string[]) => - `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; // One inbox item per booking (its `data` is the once-per-booking marker diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 18904c032..44d0615af 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -6,10 +6,13 @@ import { IsBoolean, IsDateString, IsInt, + IsNotEmpty, IsNumber, IsOptional, + IsString, IsUUID, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; + @ApiProperty({ + example: 'V-2026-0620', + maxLength: 20, + description: + 'Voyage (sailing) number for this departure — the run identifier yards and ' + + 'customs quote. Required at creation; the UI pre-fills it with the built ' + + "train's direction-matched run number, but staff may override it.", + }) + @IsString() + @IsNotEmpty({ message: 'A voyage number is required' }) + @MaxLength(20) + voyageNumber!: string; + @ApiPropertyOptional({ format: 'uuid', description: 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 index 9dc75335f..121dd5ed6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -439,6 +439,7 @@ export class IntercityService { id: booking.id, reference: booking.reference, status: booking.status, + paymentStatus: booking.paymentStatus ?? null, freightType: booking.freightType, isGovernment: booking.isGovernment, customer: booking.company?.name ?? 'Unknown customer', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index fb64b1e5c..f61593442 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => { const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: futureDeparture, + voyageNumber: 'V-TEST-1', locomotiveIds: ['loc-1', 'loc-2'], }); @@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => { service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', + voyageNumber: 'V-TEST-2', locomotiveIds: ['loc-1', 'loc-2'], }), ).rejects.toBeInstanceOf(ConflictException); @@ -1951,6 +1953,8 @@ describe('TrainSchedulingService', () => { save: jest.fn().mockResolvedValue(undefined), create: jest.fn((x: unknown) => x), })), + // Wagon-history lookup of the released allocations' physical wagons. + query: jest.fn().mockResolvedValue([]), }; beforeEach(() => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 3d4f3d2ba..4f416cdcf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -6,6 +6,7 @@ TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonAllocationSnapshot, + WagonEventType, WagonMovementKind, WagonStatus, } from '@edr/types'; @@ -72,6 +73,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -422,8 +424,28 @@ export class TrainSchedulingService { @Optional() @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService?: BookingBatchService, + // Per-wagon history ledger (global module). @Optional keeps the positional + // spec constructors working; production always has it. + @Optional() private readonly wagonHistory?: WagonHistoryService, ) {} + /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ + private async wagonsOfAllocations( + manager: EntityManager, + allocationIds: string[], + ): Promise> { + if (!allocationIds.length) return []; + return manager.query( + `SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", w.train_id AS "trainId" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + } + /** * Notify each booking's customer that their shipment was dispatched / arrived, * with a deep-link to the booking. Fire-and-forget — never blocks the action. @@ -443,8 +465,11 @@ export class TrainSchedulingService { relations: { company: true }, }); for (const b of bookings) { - if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); - else this.bookingNotifier.arrived(b, origin, destination); + if (event === 'dispatched') { + this.bookingNotifier.dispatched(b, origin, destination, schedule); + } else { + this.bookingNotifier.arrived(b, origin, destination, schedule); + } } } catch (err) { this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); @@ -1170,7 +1195,7 @@ export class TrainSchedulingService { }); for (const booking of allocatedBookings) { if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; - this.bookingNotifier.rescheduled(booking, departure); + this.bookingNotifier.rescheduled(booking, departure, schedule); notifiedCount += 1; } } @@ -1375,7 +1400,7 @@ export class TrainSchedulingService { .getRepository(Booking) .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); for (const booking of aboard) { - this.bookingNotifier.maintenanceMoved(booking, departure); + this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason); } } @@ -1872,6 +1897,10 @@ export class TrainSchedulingService { status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, + // Staff-entered at creation; the UI defaults it to the built train's + // own voyage number (Train.trainName). Fall back to the pair train + // number here only for non-UI callers that send none. + voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null, maxWagons, plannedWagonYards, reverseWagonOrder: dto.reverseWagonOrder ?? false, @@ -2415,6 +2444,21 @@ export class TrainSchedulingService { manager, ); await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + const carried = await this.wagonsOfAllocations(manager, allocationIds); + await this.wagonHistory?.record( + manager, + carried.map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.BookingUnassigned, + actorUserId: userId ?? null, + fromYardId: c.yardId, + trainId: c.trainId, + trainScheduleId: scheduleId, + bookingId, + metadata: { allocationId: c.allocationId }, + })), + ); await manager.getRepository(WagonBookingAllocation).delete(allocationIds); } @@ -2481,6 +2525,18 @@ export class TrainSchedulingService { trainSetWagonId: null, status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, }); + await this.wagonHistory?.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ReleasedFromSchedule, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: 'Booking unassigned from the dispatched train', + }); } } await manager.getRepository(TrainSetWagon).delete(slot.id); @@ -2531,7 +2587,8 @@ export class TrainSchedulingService { .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); if (removedBooking && opts.notifyCustomer !== false) { - this.bookingNotifier.removedFromTrain(removedBooking); + // The booking's train_schedule_id is already cleared — name the run explicitly. + this.bookingNotifier.removedFromTrain(removedBooking, schedule); } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, @@ -2823,10 +2880,34 @@ export class TrainSchedulingService { // The pin lives ONLY on the schedule's slot — the Wagon entity keeps // its status untouched so other schedules can still use the wagon. + const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null; await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { physicalWagonId: assignment.physicalWagonId, status: 'RESERVED', }); + if (previousPinId !== assignment.physicalWagonId) { + const pinEvents: WagonEventInput[] = [ + { + wagonId: assignment.physicalWagonId, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: schedule.originStationId ?? null, + metadata: { slotId: assignment.trainSetWagonId, auto: false }, + }, + ]; + if (previousPinId) { + pinEvents.push({ + wagonId: previousPinId, + type: WagonEventType.UnpinnedFromSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + reason: 'Replaced on the slot', + metadata: { slotId: assignment.trainSetWagonId }, + }); + } + await this.wagonHistory?.record(manager, pinEvents); + } for (const [physicalId, slotId] of slotIdByPhysicalId) { if (slotId === assignment.trainSetWagonId) { slotIdByPhysicalId.delete(physicalId); @@ -3020,6 +3101,25 @@ export class TrainSchedulingService { { id: In(dispatchedPhysicalIds) }, { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, ); + const dispatchedWagons = await manager.getRepository(Wagon).find({ + where: { id: In(dispatchedPhysicalIds) }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + await this.wagonHistory?.record( + manager, + dispatchedWagons.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { destinationYardId: schedule.destinationStationId ?? null }, + })), + ); } // Planned couples boarding at the ORIGIN join the built train now — the // departure is the moment they are physically hooked on. Mid-route @@ -3057,6 +3157,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, [ + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at the origin yard', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + }, + ]); await manager.getRepository(ScheduleWagonAdjustmentLog).save( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -3102,7 +3228,7 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL - AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) + AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`, [ scheduleId, @@ -3319,7 +3445,7 @@ export class TrainSchedulingService { AND b.loading_started_at IS NULL AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND b.is_government = false - AND (b.status = 'PAID' + AND (b.payment_status = 'PAID' OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, [scheduleId, originYardId], ); @@ -3433,7 +3559,7 @@ export class TrainSchedulingService { // milestone still counts as paid — the clearance views self-heal the row on // read, and the gate pass must not lag behind that. for (const booking of bookings) { - if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + if (booking.paymentStatus === 'PAID') { paidBookingIds.add(booking.id); } } @@ -5177,6 +5303,7 @@ export class TrainSchedulingService { ); const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; const movementRows: WagonMovement[] = []; + const historyRows: WagonEventInput[] = []; let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { const wagon = cutWagonById.get(wagonId); @@ -5184,6 +5311,31 @@ export class TrainSchedulingService { if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; if (realCutIds.has(wagonId) && builtTrainId) { // REAL cut: the built train permanently loses the wagon here. + historyRows.push( + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Available, + metadata: { permanent: true }, + }, + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt, + fromYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at this yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -5216,6 +5368,18 @@ export class TrainSchedulingService { realCutHappened = true; } else { // Soft cut: sits out the rest of this trip, stays in the build. + historyRows.push({ + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { permanent: false }, + }); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -5240,6 +5404,7 @@ export class TrainSchedulingService { if (movementRows.length) { await manager.getRepository(WagonMovement).save(movementRows); } + await this.wagonHistory?.record(manager, historyRows); // Keep the coupling order gapless after permanent removals. if (realCutHappened && builtTrainId) { const remaining = await manager.getRepository(Wagon).find({ @@ -5293,6 +5458,18 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt, + fromYardId: coupleYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at a mid-route stop', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); coupleLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -5310,6 +5487,20 @@ export class TrainSchedulingService { await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); } } + // Which wagons the position fix below will actually move — read first + // so each gets its own PASSED_CHECKPOINT history row (from → to yard). + const riding = await manager + .getRepository(Wagon) + .createQueryBuilder('w') + .select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId']) + .where('w.current_train_schedule_id = :scheduleId', { scheduleId }) + .andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', { + stationYardId: station.yardId, + }) + .getMany(); // Leg slots (booking legs boarding/alighting mid-corridor — see // stampSlotLegs) reaching their board/alight yard here: logged same as @@ -5392,6 +5583,20 @@ export class TrainSchedulingService { passedYardIds, }) .execute(); + await this.wagonHistory?.record( + manager, + riding.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.PassedCheckpoint, + occurredAt, + fromYardId: w.currentYardId ?? null, + toYardId: station.yardId, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null }, + })), + ); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5618,6 +5823,7 @@ export class TrainSchedulingService { ); const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; const arrivalMovementRows: WagonMovement[] = []; + const arrivalHistoryRows: WagonEventInput[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; const wagon = settleWagonById.get(slot.physicalWagonId); @@ -5641,6 +5847,32 @@ export class TrainSchedulingService { // Arrival fallback for a journey logged without mid-route // checkpoints: the REAL cut still permanently removes the wagon // from the built train at its cut yard. + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: WagonStatus.Available, + metadata: { permanent: true, atArrival: true }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + fromYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at its planned yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5670,6 +5902,19 @@ export class TrainSchedulingService { }), ); } else { + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5721,6 +5966,18 @@ export class TrainSchedulingService { if (!wagon) continue; if (wagon.currentTrainScheduleId === scheduleId) { // Joined during the trip, slot-less: settle at the destination. + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { loaded: false, coupledMidRoute: true }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5758,6 +6015,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentYardId: schedule.destinationStationId, }); + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + fromYardId: coupleYardId, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: arrivalMaxSeq, + reason: 'Planned couple joined on arrival', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { loaded: false, coupledMidRoute: true }, + }, + ); arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -5787,6 +6070,23 @@ export class TrainSchedulingService { // per-slot settle above never sees them. Release them here or they stay // locked to a finished schedule and no later train can pick them up. // They carry no cargo, so they simply settle where the train ended up. + const looseEmpties = await manager.getRepository(Wagon).find({ + where: { currentTrainScheduleId: scheduleId }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + arrivalHistoryRows.push( + ...looseEmpties.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: w.currentYardId ?? null, + toYardId: schedule.destinationStationId ?? null, + trainId: w.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { loaded: false, consistOnly: true }, + })), + ); await manager .getRepository(Wagon) .createQueryBuilder() @@ -5801,6 +6101,7 @@ export class TrainSchedulingService { if (arrivalLogRows.length) { await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); } + await this.wagonHistory?.record(manager, arrivalHistoryRows); if (arrivalMovementRows.length) { await manager.getRepository(WagonMovement).save(arrivalMovementRows); } @@ -5997,6 +6298,19 @@ export class TrainSchedulingService { } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { + await this.wagonHistory?.record(manager, { + wagonId: wagon.physicalWagonId, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.ReturnedOnCancel, + actorUserId: userId ?? null, + fromYardId: wagon.physicalWagon?.currentYardId ?? null, + toYardId: schedule.originStationId ?? null, + trainId: wagon.physicalWagon?.trainId ?? null, + trainScheduleId: id, + toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: dto?.reason?.trim() || 'Schedule cancelled', + metadata: { slotId: wagon.id }, + }); await manager.getRepository(Wagon).update(wagon.physicalWagonId, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -6033,7 +6347,8 @@ export class TrainSchedulingService { const booking = await this.bookingsRepository .findByIdWithFiles(sb.bookingId) .catch(() => null); - if (booking) this.bookingNotifier.scheduleCancelled(booking); + // Detached above, so pass the cancelled schedule for its train/voyage numbers. + if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule); } // Window retired (DONE) — remove the card from portal/GL lists right away. @@ -6973,6 +7288,15 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); + await this.wagonHistory?.record(manager, { + wagonId: physical.id, + wagonNumber: physical.wagonNumber, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: physical.currentYardId ?? null, + metadata: { slotId: slot.trainSetWagonId, auto: true }, + }); const pinnedSpans = occupiedSpans.get(physical.id) ?? []; pinnedSpans.push(span); occupiedSpans.set(physical.id, pinnedSpans); @@ -9049,6 +9373,21 @@ export class TrainSchedulingService { for (const wagon of removed) { await manager.getRepository(Wagon).update(wagon.id, detachPatch); } + const consistReason = (dto as { reason?: string | null }).reason?.trim() || null; + await this.wagonHistory?.record( + manager, + removed.map((wagon) => ({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: wagon.sequenceNumber, + reason: consistReason ?? 'Trimmed from the consist on the schedule', + })), + ); if (removed.length && ownSetIds.length) { // This train's own pins (all its runs) on trimmed wagons are stale — // clear them so the freed wagon isn't still claimed by slots it left. @@ -9086,6 +9425,32 @@ export class TrainSchedulingService { // Mirror on the in-memory row — the compaction below sorts by it. to.sequenceNumber = from.sequenceNumber; await manager.getRepository(Wagon).update(from.id, detachPatch); + await this.wagonHistory?.record(manager, [ + { + wagonId: to.id, + wagonNumber: to.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: to.currentYardId ?? null, + toValue: from.sequenceNumber, + reason: consistReason ?? `Switched in for ${from.wagonNumber}`, + metadata: { replaced: from.wagonNumber, replacedWagonId: from.id }, + }, + { + wagonId: from.id, + wagonNumber: from.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: from.sequenceNumber, + reason: consistReason ?? `Switched out for ${to.wagonNumber}`, + metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id }, + }, + ]); } const remaining = consist.filter( @@ -9101,6 +9466,7 @@ export class TrainSchedulingService { } } let sequence = compacted.length; + const addedEvents: WagonEventInput[] = []; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -9108,7 +9474,20 @@ export class TrainSchedulingService { sequenceNumber: sequence, status: WagonStatus.Assigned, }); + addedEvents.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + reason: consistReason ?? 'Added to the consist on the schedule', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); } + await this.wagonHistory?.record(manager, addedEvents); // The schedule is full when every consist wagon is allocated. await manager @@ -10637,6 +11016,8 @@ export class TrainSchedulingService { // without the wagons' tare. The legs tab shows this per booking. cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, + // Loadability is decided by the payment status, not `status`. + paymentStatus: sb.booking?.paymentStatus ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, // Which leg of the corridor this booking rides — the workspace can't @@ -11632,6 +12013,30 @@ export class TrainSchedulingService { await allocs.update(alloc.id, { trainSetWagonId: created.id }); } await slotRepo.update(source.id, emptyLoadFields); + await this.wagonHistory?.record(manager, [ + ...(source.physicalWagonId + ? [ + { + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: consistWagon.wagonNumber, + metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length }, + }, + ] + : []), + { + wagonId: consistWagon.id, + wagonNumber: consistWagon.wagonNumber, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length }, + }, + ]); return; } @@ -11646,6 +12051,54 @@ export class TrainSchedulingService { } await slotRepo.update(target.id, sourceLoadFields); await slotRepo.update(source.id, targetLoadFields); + const moveEvents: WagonEventInput[] = []; + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (targetAllocs.length) { + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + toValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + fromValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + } + await this.wagonHistory?.record(manager, moveEvents); }); return this.getTrainScheduleById(scheduleId); @@ -11950,7 +12403,11 @@ export class TrainSchedulingService { return assignability.shortage; } - /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + /** + * Paid (or government) bookings that may be loaded onto wagons — excludes + * expired / awaiting payment. "Paid" is read from the PAYMENT status only; + * the booking status is not a reliable payment signal. + */ private isReadyToLoadBooking(booking: { status: string; paymentStatus?: string | null; @@ -11960,7 +12417,7 @@ export class TrainSchedulingService { if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { return false; } - if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; if (booking.isGovernment) return true; return false; } @@ -12340,6 +12797,12 @@ export class TrainSchedulingService { // 2. The physical wagons follow the train — the target's stay put, and // EVERY wagon on the source train (coupled or loose) moves across so // nothing strands on the deactivated train. + const mergedFromSource = sourceTrainId + ? await manager.getRepository(Wagon).find({ + where: { trainId: sourceTrainId }, + select: { id: true, wagonNumber: true, currentYardId: true }, + }) + : []; if (incomingWagons.length) { await manager.getRepository(Wagon).update( { id: In(incomingWagons.map((w) => w.id)) }, @@ -12351,6 +12814,20 @@ export class TrainSchedulingService { .getRepository(Wagon) .update({ trainId: sourceTrainId }, { trainId: targetTrain.id }); } + await this.wagonHistory?.record( + manager, + mergedFromSource.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainMerged, + fromYardId: w.currentYardId ?? null, + trainId: targetTrain.id, + trainScheduleId: schedule.id, + fromValue: sourceTrainId, + toValue: targetTrain.code, + reason: `Train merged into ${targetTrain.code}`, + })), + ); // 3. Carry the target's train-set wagon rows into THIS consist, appended // after the existing wagons. Sequence is provisional — staff reorder diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 26c29499d..8160127b0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { Freight, WagonEventType, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -25,6 +25,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; @@ -76,6 +77,7 @@ export class TrainBuilderService { constructor( private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, + private readonly wagonHistory: WagonHistoryService, ) {} async buildTrain(dto: BuildTrainDto) { @@ -134,7 +136,7 @@ export class TrainBuilderService { await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); if (dto.wagonIds?.length) { - await this.attachWagons(manager, train, dto.wagonIds, 0); + await this.attachWagons(manager, train, dto.wagonIds, 0, null); } return train.id; }); @@ -684,8 +686,19 @@ export class TrainBuilderService { wagon.currentYardId === previousYardId, ); const now = new Date(); + const events: WagonEventInput[] = []; for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedWithTrain, + occurredAt: now, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: `Train ${train.code} relocated`, + }); await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); // Ledger row keeps the wagon's yard history auditable (mirrors the // manual-relocation path in the wagons service). @@ -699,6 +712,7 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, events); }); return this.getComposition(id); } @@ -735,6 +749,16 @@ export class TrainBuilderService { occurredAt: new Date(), }), ); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagon moved from the train builder', + }); }); return this.getComposition(id); } @@ -787,6 +811,19 @@ export class TrainBuilderService { await manager .getRepository(Wagon) .update(moving.map((w) => w.id), { currentYardId: yard.id }); + await this.wagonHistory.record( + manager, + moving.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagons moved from the train builder', + })), + ); await manager.getRepository(WagonMovement).save( moving.map((w) => manager.getRepository(WagonMovement).create({ @@ -810,7 +847,7 @@ export class TrainBuilderService { const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); - const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount, userId ?? null); return this.syncLiveScheduleAfterConsistChange( manager, train.id, @@ -949,6 +986,18 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? train.currentYardId ?? null, + fromValue: previousStatus, + toValue: WagonStatus.Maintenance, + reason: note?.trim() || null, + metadata: { trainCode: train.code }, + }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -1166,6 +1215,22 @@ export class TrainBuilderService { for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + const previousSeq = new Map(wagons.map((w) => [w.id, w])); + await this.wagonHistory.record( + manager, + dto.wagonIds + .map((wid, i) => ({ wagon: previousSeq.get(wid), to: i + 1 })) + .filter((x) => x.wagon && x.wagon.sequenceNumber !== x.to) + .map(({ wagon, to }) => ({ + wagonId: wagon!.id, + wagonNumber: wagon!.wagonNumber, + type: WagonEventType.SequenceChanged, + trainId: train.id, + fromValue: wagon!.sequenceNumber, + toValue: to, + reason: 'Consist reordered', + })), + ); // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of // this train: slots pinned to a reordered wagon adopt the wagon's new @@ -1287,6 +1352,10 @@ export class TrainBuilderService { 'Train has active schedules; cancel them before disbanding the train', ); } + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, wagonNumber: true, currentYardId: true, sequenceNumber: true, status: true }, + }); await manager .getRepository(Wagon) .update( @@ -1299,6 +1368,19 @@ export class TrainBuilderService { exportTrainNumber: null, }, ); + await this.wagonHistory.record( + manager, + consist.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainDisbanded, + trainId: train.id, + fromYardId: w.currentYardId ?? null, + fromValue: w.sequenceNumber, + reason: `Train ${train.code} disbanded`, + metadata: { status: { from: w.status, to: WagonStatus.Available } }, + })), + ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); }); @@ -1432,6 +1514,25 @@ export class TrainBuilderService { ), ); + // COUPLED rows are written by attachWagons (build + assign); the detach + // side is logged here, where the reason and the live schedule are known. + await this.wagonHistory.record( + manager, + changes + .filter((c) => c.action === 'REMOVE') + .map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + actorUserId: userId, + trainId, + trainScheduleId: schedule?.id ?? null, + fromYardId: yardId, + reason: reason?.trim() || null, + })), + ); + if (!schedule) return null; await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); @@ -1543,6 +1644,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, + userId: string | null = null, ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -1572,6 +1674,7 @@ export class TrainBuilderService { await this.assertConsistLengthWithinLimit(manager, train, toAttach); let sequence = startCount; + const events: WagonEventInput[] = []; for (const wagon of toAttach) { sequence += 1; await wagonRepo.update(wagon.id, { @@ -1583,7 +1686,23 @@ export class TrainBuilderService { importTrainNumber: train.importTrainNumber, exportTrainNumber: train.exportTrainNumber, }); + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + metadata: { + trainCode: train.code, + status: { from: wagon.status, to: WagonStatus.Assigned }, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, + }, + }); } + await this.wagonHistory.record(manager, events); return toAttach; } diff --git a/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts new file mode 100644 index 000000000..3ad9748d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts @@ -0,0 +1,47 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class WagonHistoryQueryDto { + @ApiPropertyOptional({ enum: WagonEventCategory, description: 'Only events of this category' }) + @IsOptional() + @IsEnum(WagonEventCategory) + category?: WagonEventCategory; + + @ApiPropertyOptional({ + enum: WagonEventType, + isArray: true, + description: 'Only these event types (repeat the param or comma-separate)', + }) + @IsOptional() + @Transform(({ value }) => + Array.isArray(value) ? value : String(value).split(',').map((v) => v.trim()).filter(Boolean), + ) + @IsArray() + @IsEnum(WagonEventType, { each: true }) + types?: WagonEventType[]; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or after this moment' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or before this moment' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ description: 'Opaque `nextCursor` from the previous page' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts new file mode 100644 index 000000000..c0dedf760 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts @@ -0,0 +1,74 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * Append-only history of everything that happens to a wagon — one row per + * wagon per transition, written inside the same transaction as the change. + * Plain id columns, no foreign keys and no soft delete on purpose: the history + * must outlive the wagon, train, schedule or booking it refers to, exactly like + * `audit_logs` and `schedule_wagon_adjustment_logs`. Rows are never updated. + * + * Read path: `(wagon_id, occurred_at DESC, id DESC)` keyset pagination — one + * index range scan per page regardless of how long the wagon has been in + * service. Labels (yard, train, schedule, booking, actor) are joined at read + * time on primary keys, so the write path stays a single INSERT. + */ +@Entity({ schema: 'freight', name: 'wagon_events' }) +@Index('idx_wagon_events_wagon_time', ['wagonId', 'occurredAt', 'id']) +@Index('idx_wagon_events_wagon_cat_time', ['wagonId', 'category', 'occurredAt', 'id']) +export class WagonEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot so the row still reads after the wagon is purged or renumbered. */ + @Column({ name: 'wagon_number', type: 'varchar', nullable: true }) + wagonNumber?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 40 }) + type!: WagonEventType; + + /** Derived from `type` at write time; stored so the category filter hits the index. */ + @Column({ name: 'category', type: 'varchar', length: 20 }) + category!: WagonEventCategory; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @Column({ name: 'to_yard_id', type: 'uuid', nullable: true }) + toYardId?: string | null; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId?: string | null; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + /** Previous value of whatever the event changed (status, sequence, train code…). */ + @Column({ name: 'from_value', type: 'varchar', length: 120, nullable: true }) + fromValue?: string | null; + + @Column({ name: 'to_value', type: 'varchar', length: 120, nullable: true }) + toValue?: string | null; + + /** Staff-entered reason / note, when the action carried one. */ + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts new file mode 100644 index 000000000..d049578d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** + * Global, dependency-free (only the DataSource): every service that writes a + * wagon row — wagons desk, train builder, scheduling, booking journey, + * containers, cancellations — records history through WagonHistoryService + * without adding a module edge, the same pattern as FleetHistoryModule. + */ +@Global() +@Module({ + providers: [WagonHistoryService], + exports: [WagonHistoryService], +}) +export class WagonHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts new file mode 100644 index 000000000..0855d7c6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts @@ -0,0 +1,135 @@ +import { BadRequestException } from '@nestjs/common'; +import { WagonEventCategory, WagonEventType } from '@edr/types'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** Captures the INSERT query-builder chain and the raw list query. */ +function makeDataSource() { + const execute = jest.fn().mockResolvedValue(undefined); + const values = jest.fn(); + const chain = { insert: jest.fn(), into: jest.fn(), values, updateEntity: jest.fn(), execute }; + chain.insert.mockReturnValue(chain); + chain.into.mockReturnValue(chain); + values.mockReturnValue(chain); + chain.updateEntity.mockReturnValue(chain); + const manager = { createQueryBuilder: jest.fn(() => chain) }; + const query = jest.fn().mockResolvedValue([]); + return { dataSource: { manager, query }, manager, values, execute, query }; +} + +describe('WagonHistoryService.record', () => { + it('writes a batch as one INSERT, deriving the category from the type', async () => { + const { dataSource, values, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + const at = new Date('2026-09-01T10:00:00Z'); + + await service.record(dataSource.manager as never, [ + { wagonId: 'w1', wagonNumber: 'W-1', type: WagonEventType.MovedManually, toYardId: 'y2', occurredAt: at }, + { wagonId: 'w2', type: WagonEventType.CargoLoaded, bookingId: 'b1', toValue: 12.5 }, + null, + ]); + + expect(execute).toHaveBeenCalledTimes(1); + const rows = values.mock.calls[0][0]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.MovedManually, + category: WagonEventCategory.Yard, + toYardId: 'y2', + occurredAt: at, + actorUserId: null, + }); + expect(rows[1]).toMatchObject({ + wagonId: 'w2', + category: WagonEventCategory.Cargo, + bookingId: 'b1', + toValue: '12.5', + }); + expect(rows[1].occurredAt).toBeInstanceOf(Date); + }); + + it('skips empty input without touching the database', async () => { + const { dataSource, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await service.record(dataSource.manager as never, []); + await service.record(null, null); + expect(execute).not.toHaveBeenCalled(); + }); + + it('propagates a failure inside a caller transaction but swallows it outside one', async () => { + const { dataSource, execute } = makeDataSource(); + execute.mockRejectedValue(new Error('db down')); + const service = new WagonHistoryService(dataSource as never); + const input = { wagonId: 'w1', type: WagonEventType.Registered }; + + await expect(service.record(dataSource.manager as never, input)).rejects.toThrow('db down'); + await expect(service.record(null, input)).resolves.toBeUndefined(); + }); +}); + +describe('WagonHistoryService.list', () => { + const A = '11111111-1111-4111-8111-111111111111'; + const B = '22222222-2222-4222-8222-222222222222'; + const C = '33333333-3333-4333-8333-333333333333'; + const row = (id: string, at: string) => ({ + id, + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.PassedCheckpoint, + category: WagonEventCategory.Yard, + occurredAt: new Date(at), + actorUserId: null, + actorName: null, + fromYardId: 'y1', + fromYardLabel: 'Origin', + toYardId: 'y2', + toYardLabel: 'Stop', + trainId: null, + trainCode: null, + trainScheduleId: 's1', + scheduleLabel: 'V-100', + bookingId: null, + bookingReference: null, + fromValue: null, + toValue: null, + reason: null, + metadata: null, + }); + + it('returns a page with a cursor when more rows exist, and decodes that cursor on the next call', async () => { + const { dataSource, query } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + query.mockResolvedValueOnce([ + row(A, '2026-09-01T10:00:00Z'), + row(B, '2026-09-01T09:00:00Z'), + row(C, '2026-09-01T08:00:00Z'), // the +1 probe row + ]); + + const first = await service.list('w1', { limit: 2, category: WagonEventCategory.Yard }); + expect(first.items.map((i) => i.id)).toEqual([A, B]); + expect(first.items[0].occurredAt).toBe('2026-09-01T10:00:00.000Z'); + expect(first.nextCursor).toEqual(expect.any(String)); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('e.wagon_id = $1'); + expect(sql).toContain('e.category = $2'); + expect(sql).toContain('LIMIT 3'); + expect(params).toEqual(['w1', WagonEventCategory.Yard]); + + query.mockResolvedValueOnce([row(C, '2026-09-01T08:00:00Z')]); + const second = await service.list('w1', { limit: 2, cursor: first.nextCursor! }); + expect(second.items.map((i) => i.id)).toEqual([C]); + expect(second.nextCursor).toBeNull(); + const [sql2, params2] = query.mock.calls[1]; + expect(sql2).toContain('(e.occurred_at, e.id) < ($2, $3::uuid)'); + expect(params2[1]).toEqual(new Date('2026-09-01T09:00:00Z')); + expect(params2[2]).toBe(B); + }); + + it('rejects a malformed cursor', async () => { + const { dataSource } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await expect(service.list('w1', { cursor: 'not-a-cursor' })).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts new file mode 100644 index 000000000..982e927ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts @@ -0,0 +1,195 @@ +import { + WAGON_EVENT_CATEGORY, + WagonEventCategory, + WagonEventType, + WagonHistoryEvent, + WagonHistoryPage, +} from '@edr/types'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { WagonHistoryQueryDto } from './dto/wagon-history-query.dto'; +import { WagonEvent } from './wagon-event.entity'; + +/** One transition to append. Everything but the wagon and the type is optional context. */ +export interface WagonEventInput { + wagonId: string; + /** Snapshot for the row; pass it when the caller already holds the wagon (no lookup is made). */ + wagonNumber?: string | null; + type: WagonEventType; + /** Defaults to now. Pass the business timestamp when the caller has one. */ + occurredAt?: Date | null; + actorUserId?: string | null; + fromYardId?: string | null; + toYardId?: string | null; + trainId?: string | null; + trainScheduleId?: string | null; + bookingId?: string | null; + fromValue?: string | number | null; + toValue?: string | number | null; + reason?: string | null; + metadata?: Record | null; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +/** + * The single write and read path for `freight.wagon_events`. + * + * Writes: {@link record} takes the caller's EntityManager so the history row + * commits (or rolls back) with the business change — a wagon can never end up + * moved without its history row or vice versa. A batch is one INSERT. + * + * Reads: {@link list} is keyset-paginated on `(occurred_at, id)` under the + * per-wagon index, so page N costs the same as page 1; labels come from + * primary-key LEFT JOINs on the page only. + */ +@Injectable() +export class WagonHistoryService { + private readonly logger = new Logger(WagonHistoryService.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * Append one or more events. Inside a transaction (manager given) a failure + * propagates — Postgres has already aborted the transaction at that point, + * so swallowing it would only hide the rollback. Outside a transaction the + * write is best-effort: logged, never thrown, so history can't break the + * operation that produced it. + */ + async record( + manager: EntityManager | null | undefined, + input: WagonEventInput | null | Array, + ): Promise { + const inputs = (Array.isArray(input) ? input : [input]).filter( + (i): i is WagonEventInput => Boolean(i?.wagonId), + ); + if (!inputs.length) return; + const now = new Date(); + const rows = inputs.map((i) => ({ + wagonId: i.wagonId, + wagonNumber: i.wagonNumber ?? null, + type: i.type, + category: WAGON_EVENT_CATEGORY[i.type] ?? WagonEventCategory.Lifecycle, + occurredAt: i.occurredAt ?? now, + actorUserId: i.actorUserId ?? null, + fromYardId: i.fromYardId ?? null, + toYardId: i.toYardId ?? null, + trainId: i.trainId ?? null, + trainScheduleId: i.trainScheduleId ?? null, + bookingId: i.bookingId ?? null, + fromValue: i.fromValue == null ? null : String(i.fromValue).slice(0, 120), + toValue: i.toValue == null ? null : String(i.toValue).slice(0, 120), + reason: i.reason?.trim() ? i.reason.trim() : null, + metadata: i.metadata ?? null, + })); + const mg = manager ?? this.dataSource.manager; + const write = () => + mg + .createQueryBuilder() + .insert() + .into(WagonEvent) + .values(rows as unknown as QueryDeepPartialEntity[]) + .updateEntity(false) + .execute(); + if (manager) { + await write(); + return; + } + try { + await write(); + } catch (err) { + this.logger.error( + `Failed to record ${rows.length} wagon event(s) (${rows[0].type}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + /** One wagon's timeline, newest first, with labels resolved. */ + async list(wagonId: string, query: WagonHistoryQueryDto = {}): Promise { + const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const params: unknown[] = [wagonId]; + const where: string[] = ['e.wagon_id = $1']; + const push = (value: unknown) => { + params.push(value); + return `$${params.length}`; + }; + if (query.category) where.push(`e.category = ${push(query.category)}`); + if (query.types?.length) where.push(`e.event_type = ANY(${push(query.types)}::text[])`); + if (query.from) where.push(`e.occurred_at >= ${push(new Date(query.from))}`); + if (query.to) where.push(`e.occurred_at <= ${push(new Date(query.to))}`); + const cursor = decodeCursor(query.cursor); + if (cursor) { + // Row-value comparison walks the (wagon_id, occurred_at DESC, id DESC) index directly. + where.push(`(e.occurred_at, e.id) < (${push(cursor.occurredAt)}, ${push(cursor.id)}::uuid)`); + } + + const rows: Array = await this.dataSource.query( + `SELECT e.id, + e.wagon_id AS "wagonId", + e.wagon_number AS "wagonNumber", + e.event_type AS "type", + e.category, + e.occurred_at AS "occurredAt", + e.actor_user_id AS "actorUserId", + COALESCE(u.username, u.email) AS "actorName", + e.from_yard_id AS "fromYardId", + fy.label AS "fromYardLabel", + e.to_yard_id AS "toYardId", + ty.label AS "toYardLabel", + e.train_id AS "trainId", + t.code AS "trainCode", + e.train_schedule_id AS "trainScheduleId", + COALESCE(s.voyage_number, s.train_number) AS "scheduleLabel", + e.booking_id AS "bookingId", + b.reference AS "bookingReference", + e.from_value AS "fromValue", + e.to_value AS "toValue", + e.reason, + e.metadata + FROM freight.wagon_events e + LEFT JOIN iam.users u ON u.id = e.actor_user_id + LEFT JOIN freight.yards fy ON fy.id = e.from_yard_id + LEFT JOIN freight.yards ty ON ty.id = e.to_yard_id + LEFT JOIN freight.trains t ON t.id = e.train_id + LEFT JOIN freight.train_schedules s ON s.id = e.train_schedule_id + LEFT JOIN freight.bookings b ON b.id = e.booking_id + WHERE ${where.join(' AND ')} + ORDER BY e.occurred_at DESC, e.id DESC + LIMIT ${limit + 1}`, + params, + ); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page[page.length - 1]; + return { + items: page.map((r) => ({ + ...r, + occurredAt: new Date(r.occurredAt).toISOString(), + })), + nextCursor: hasMore && last ? encodeCursor(new Date(last.occurredAt), last.id) : null, + }; + } +} + +function encodeCursor(occurredAt: Date, id: string): string { + return Buffer.from(`${occurredAt.toISOString()}|${id}`, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor?: string): { occurredAt: Date; id: string } | null { + if (!cursor) return null; + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const sep = raw.indexOf('|'); + const occurredAt = sep > 0 ? new Date(raw.slice(0, sep)) : new Date(NaN); + const id = sep > 0 ? raw.slice(sep + 1) : ''; + if (Number.isNaN(occurredAt.getTime()) || !/^[0-9a-f-]{36}$/i.test(id)) { + throw new BadRequestException('Invalid history cursor'); + } + return { occurredAt, id }; +} diff --git a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts index ab86cea51..d86f65367 100644 --- a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts @@ -14,7 +14,12 @@ const makeService = (wagon: any, counts: [number, number, number], pinned = fals return []; }), }; - const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + dataSource as any, + { record: jest.fn() } as any, + ); return { svc, wagonRepo }; }; @@ -54,7 +59,12 @@ describe('WagonsService.purge', () => { it('404s an unknown wagon', async () => { const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() }; - const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + { query: jest.fn() } as any, + { record: jest.fn() } as any, + ); await expect(svc.purge('nope')).rejects.toThrow(NotFoundException); expect(wagonRepo.remove).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index e4492e5cb..89c2fd9f7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -27,6 +27,8 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; +import { WagonHistoryQueryDto } from '../wagon-history/dto/wagon-history-query.dto'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @ApiTags('wagons') // No class-level guard: reads (list, by-id, movements) are login-only reference @@ -34,13 +36,16 @@ import { WagonsService } from './wagons.service'; // fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage(). @Controller('wagons') export class WagonsController { - constructor(private readonly wagonsService: WagonsService) {} + constructor( + private readonly wagonsService: WagonsService, + private readonly wagonHistory: WagonHistoryService, + ) {} @Post() @FleetManage(FREIGHT_PERMS.wagons.create) @ApiOperation({ summary: 'Create a new wagon' }) - create(@Body() dto: CreateWagonDto) { - return this.wagonsService.create(dto); + create(@Body() dto: CreateWagonDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.create(dto, user?.id); } @Get() @@ -68,11 +73,26 @@ export class WagonsController { return this.wagonsService.listMovements(id); } + @Get(':id/history') + @FleetView(FREIGHT_PERMS.wagons.view) + @ApiOperation({ + summary: + 'Unified wagon history — yard moves, coupling, schedule pins/dispatch, status flips, cargo, lifecycle — newest first, keyset-paginated (`cursor`)', + }) + history(@Param('id', ParseUUIDPipe) id: string, @Query() query: WagonHistoryQueryDto) { + // No existence check on purpose: a deleted or purged wagon keeps its history. + return this.wagonHistory.list(id, query); + } + @Patch(':id') @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Update a wagon' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { - return this.wagonsService.update(id, dto); + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateWagonDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.update(id, dto, user?.id); } // Declared before @Delete(':id') so "permanent" is never captured as an id. @@ -86,29 +106,33 @@ export class WagonsController { summary: 'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)', }) - purge(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.purge(id); + purge(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.purge(id, user?.id); } @Delete(':id') @FleetManage(FREIGHT_PERMS.wagons.delete) @ApiOperation({ summary: 'Delete a wagon' }) - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.remove(id); + remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.remove(id, user?.id); } @Post(':id/assign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Assign wagon to a train' }) - assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { - return this.wagonsService.assignToTrain(id, dto); + assignToTrain( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignWagonToTrainDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.assignToTrain(id, dto, user?.id); } @Post(':id/unassign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Unassign wagon from train' }) - unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.unassignFromTrain(id); + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.unassignFromTrain(id, user?.id); } @Post('bulk-transfer') diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 1ae1093f2..f15722b67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,10 @@ -import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types'; +import { + Freight, + PaginatedResponse, + WagonEventType, + WagonMovementKind, + WagonStatus, +} from '@edr/types'; import { BadRequestException, Injectable, @@ -19,6 +25,16 @@ import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; + +/** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */ +const TRACKED_DETAIL_FIELDS = [ + 'wagonNumber', + 'wagonTypeId', + 'exportTrainNumber', + 'importTrainNumber', + 'notes', +] as const; @Injectable() export class WagonsService { @@ -28,9 +44,10 @@ export class WagonsService { @InjectRepository(Train) private readonly trainRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} - async create(dto: CreateWagonDto): Promise { + async create(dto: CreateWagonDto, userId?: string | null): Promise { const wagon = this.wagonRepo.create({ ...dto, status: dto.status ?? WagonStatus.Available, @@ -41,7 +58,22 @@ export class WagonsService { if (dto.currentYardId === undefined) wagon.currentYardId = null; if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: saved.id, + wagonNumber: saved.wagonNumber, + type: WagonEventType.Registered, + actorUserId: userId ?? null, + toYardId: saved.currentYardId ?? null, + trainId: saved.trainId ?? null, + toValue: saved.status, + metadata: { + wagonTypeId: saved.wagonTypeId, + exportTrainNumber: saved.exportTrainNumber ?? null, + importTrainNumber: saved.importTrainNumber ?? null, + }, + }); + return saved; } /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ @@ -210,6 +242,10 @@ export class WagonsService { } } const previousYardId = wagon.currentYardId ?? null; + const previousStatus = wagon.status; + const before = Object.fromEntries( + TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record)[f] ?? null]), + ); Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation @@ -243,6 +279,47 @@ export class WagonsService { }), ); } + // History: one row per kind of change — a yard move, a status flip, and + // the remaining field edits as a single diff. + const events: WagonEventInput[] = []; + const changes: Record = {}; + for (const f of TRACKED_DETAIL_FIELDS) { + if (dto[f] === undefined) continue; + const to = (wagon as unknown as Record)[f] ?? null; + if (before[f] !== to) changes[f] = { from: before[f], to }; + } + if (Object.keys(changes).length) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.DetailsUpdated, + actorUserId: userId ?? null, + metadata: { changes }, + }); + } + if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId: dto.currentYardId ?? null, + reason: 'Wagon record edited', + }); + } + if (dto.status !== undefined && dto.status !== previousStatus) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + fromValue: previousStatus, + toValue: dto.status, + reason: 'Wagon record edited', + }); + } + await this.wagonHistory.record(null, events); // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); @@ -258,7 +335,7 @@ export class WagonsService { }); } - async remove(id: string): Promise { + async remove(id: string, userId?: string | null): Promise { const wagon = await this.findById(id); // A coupled wagon must be detached via train-builder before it can be // removed, so a built train never silently loses a wagon. @@ -275,6 +352,14 @@ export class WagonsService { // Soft delete (deleted_at) — hard-deleting would strand ledger/schedule // history that references this wagon. await this.wagonRepo.softRemove(wagon); + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Deleted, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); } /** @@ -288,7 +373,7 @@ export class WagonsService { * * Soft-deleted wagons are purgeable, so `withDeleted` is used to find them. */ - async purge(id: string): Promise { + async purge(id: string, userId?: string | null): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, withDeleted: true, @@ -343,6 +428,16 @@ export class WagonsService { ); } + // Recorded BEFORE the row goes: wagon_events has no FK, so the history of + // a purged wagon survives under its id and number snapshot. + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Purged, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); await this.wagonRepo.remove(wagon); } @@ -366,7 +461,11 @@ export class WagonsService { return rows.length > 0; } - async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + async assignToTrain( + wagonId: string, + dto: AssignWagonToTrainDto, + userId?: string | null, + ): Promise { const wagon = await this.findById(wagonId); // Mirror train-builder attachWagons: only a truly free, available wagon // (any yard) can be coupled, and never onto a dispatched train. @@ -399,13 +498,25 @@ export class WagonsService { ); } + const previousStatus = wagon.status; wagon.trainId = train.id; wagon.sequenceNumber = nextSequence; wagon.status = WagonStatus.Assigned; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: nextSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code }, + }); + return saved; } - async unassignFromTrain(wagonId: string): Promise { + async unassignFromTrain(wagonId: string, userId?: string | null): Promise { const wagon = await this.findById(wagonId); // A wagon pinned to a live schedule is still operationally committed even // if the fleet train is being edited — don't free it out from under it. @@ -414,10 +525,24 @@ export class WagonsService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`, ); } + const previousTrainId = wagon.trainId; + const previousSequence = wagon.sequenceNumber; + const previousStatus = wagon.status; wagon.trainId = null; wagon.sequenceNumber = null; wagon.status = WagonStatus.Available; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: previousTrainId, + fromYardId: wagon.currentYardId ?? null, + fromValue: previousSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Available } }, + }); + return saved; } /** @@ -464,9 +589,20 @@ export class WagonsService { } let moved = 0; + const events: WagonEventInput[] = []; for (const wagon of wagons) { const previousYardId = wagon.currentYardId ?? null; if (previousYardId === toYardId) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId, + reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer', + metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null, + }); wagon.currentYardId = toYardId; // Drop the eager relation so the scalar FK wins on save (see `update`). wagon.currentYard = null; @@ -484,6 +620,7 @@ export class WagonsService { ); moved++; } + await this.wagonHistory.record(queryRunner.manager, events); await queryRunner.commitTransaction(); return { moved }; @@ -547,6 +684,18 @@ export class WagonsService { } await queryRunner.manager.save(Wagon, wagons); if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs); + await this.wagonHistory.record( + queryRunner.manager, + logs.map((l) => ({ + wagonId: l.wagonId, + wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null, + type: WagonEventType.StatusChanged, + actorUserId: changedByUserId ?? null, + fromValue: l.fromStatus, + toValue: l.toStatus, + reason: dto.note ?? null, + })), + ); await queryRunner.commitTransaction(); return { updated: wagons.length }; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index eb3ae97b3..82231bacc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -129,6 +129,7 @@ interface BookingSummaryRow { id: string; reference: string | null; status: string | null; + paymentStatus: string | null; customer: string | null; } @@ -1372,14 +1373,19 @@ export class WarehouseInventoryService { return this.findById(saved.id); } - /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + /** + * Auto-load all READY_FOR_LOADING inventory whose booking is paid (payment + * status PAID — the booking status is not consulted). Unpaid stay pending. + */ async autoLoadReady(): Promise { const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; for (const item of ready) { - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { + const paymentStatus = item.bookingId + ? await this.getBookingPaymentStatus(item.bookingId) + : null; + if (paymentStatus !== 'PAID') { result.skippedCount += 1; result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); continue; @@ -3771,12 +3777,14 @@ export class WarehouseInventoryService { throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); } - const status = await this.getBookingStatus(dto.bookingId); - if (!status) { + const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId); + if (paymentStatus === null) { throw new NotFoundException(`Booking ${dto.bookingId} not found`); } - if (status !== 'PAID') { - throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + if (paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking must be paid to reserve inventory (payment status: ${paymentStatus})`, + ); } await this.dataSource.transaction(async (manager) => { @@ -7468,12 +7476,19 @@ export class WarehouseInventoryService { }; } - private async getBookingStatus(bookingId: string): Promise { - const [row]: Array<{ status: string | null }> = await this.dataSource.query( - 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + /** + * The booking's PAYMENT status — the only signal loading/reservation gates + * use to decide "paid". Returns null when the booking does not exist; + * an existing booking with no payment status yet reads as PENDING. + */ + private async getBookingPaymentStatus(bookingId: string): Promise { + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, [bookingId], ); - return row?.status ?? null; + if (!row) return null; + return row.paymentStatus ?? 'PENDING'; } private async attachBookingSummaries(items: WarehouseInventory[]): Promise { @@ -7481,7 +7496,8 @@ export class WarehouseInventoryService { if (bookingIds.length === 0) return; const rows: BookingSummaryRow[] = await this.dataSource.query( - `SELECT b.id, b.reference, b.status, company.name AS customer + `SELECT b.id, b.reference, b.status, b.payment_status AS "paymentStatus", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, @@ -7495,6 +7511,7 @@ export class WarehouseInventoryService { Object.assign(item, { bookingReference: summary.reference, bookingStatus: summary.status, + bookingPaymentStatus: summary.paymentStatus, customerName: summary.customer, }); }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cf574f588..0a3d26d71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2852,6 +2852,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + // GL rebooks cancelled-wagon credits on the customer's behalf — whoever + // cancelled (customer or staff) and whichever side was at fault. Needs to + // see the ledger rows and to redeem the credit. + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.contracts.finalInvoiceConfirm, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx new file mode 100644 index 000000000..2d37be840 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react"; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/auth/http"; +import { toDayString } from "@/hooks/useListControls"; +import { formatMoney } from "@/lib/format"; +import { + hasOddFt20, + type RebookPartnerCandidate, + type WagonCancellation, +} from "./types"; + +/** Editable rebook unit — prefilled from the cancelled snapshot. */ +interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => + (r.cancelledQuantities?.units ?? []).map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })); + +const containersPayload = (drafts: RebookUnitDraft[]) => { + const bySize = new Map(); + for (const d of drafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); +}; + +/** + * Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment + * day, correct container details if they changed, and — for an odd-20ft + * credit — pick the consolidation partner that shares the wagon. The server + * creates the new booking under the contract and marks it PAID from the credit. + * Used by the wagon-cancellations list, the GL clearance page and the staff + * booking page, so every desk gets the same flow. + */ +export function RebookWagonCancellationModal({ + cancellation, + onClose, + onRebooked, +}: { + cancellation: WagonCancellation | null; + onClose: () => void; + /** Called after a successful rebook with the new booking id (when the API returns it). */ + onRebooked?: (result: { bookingId?: string }) => void; +}) { + const [date, setDate] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [drafts, setDrafts] = useState([]); + + // Fresh form per row: the modal instance is long-lived on the host page. + useEffect(() => { + setDate(null); + setPartnerId(null); + setDrafts(cancellation ? draftsFrom(cancellation) : []); + }, [cancellation]); + + const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + const partners = useQuery({ + queryKey: [ + "wagon-cancellations", + cancellation?.id, + "rebook-partners", + date ? toDayString(date) : null, + ], + enabled: Boolean(cancellation && needsPartner && date), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(date!) } }, + ); + return res.data; + }, + }); + + const rebook = useMutation({ + mutationFn: async () => { + const res = await api.post<{ bookingId?: string }>( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, + { + scheduledDate: toDayString(date!), + ...(drafts.length ? { containers: containersPayload(drafts) } : {}), + ...(partnerId ? { partnerBookingId: partnerId } : {}), + }, + ); + return res.data ?? {}; + }, + }); + + const patchDraft = (i: number, patch: Partial) => + setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); + + return ( + + {cancellation && ( + + + {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} + {cancellation.wagonsCancelled} wagon(s) · credit{" "} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + + { + setDate(v ? new Date(v) : null); + setPartnerId(null); + }} + minDate={new Date()} + radius="md" + /> + {needsPartner && ( + ({ - value: c.id, - label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`, - }))} - value={rebookPartnerId} - onChange={setRebookPartnerId} - disabled={!rebookDate} - searchable - radius="md" - /> - )} - {rebookNeedsPartner && - rebookDate && - !rebookPartners.isLoading && - (rebookPartners.data ?? []).length === 0 && ( - - No odd-20ft booking rides that day — pick another day or wait - for a partner booking. - - )} - {rebookDrafts.length > 0 && ( - - - Correct the container details if they changed — sizes and - quantities stay as cancelled. - - {rebookDrafts.map((d, i) => ( - - { - const v = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i ? { ...x, containerNumber: v } : x, - ), - ); - }} - size="xs" - radius="md" - style={{ flex: 1.4 }} - /> - { - const v = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i ? { ...x, sealNumber: v } : x, - ), - ); - }} - size="xs" - radius="md" - style={{ flex: 1 }} - /> - { - const raw = e.currentTarget.value; - setRebookDrafts((prev) => - prev.map((x, idx) => - idx === i - ? { ...x, vgmTons: raw === "" ? "" : Number(raw) } - : x, - ), - ); - }} - size="xs" - radius="md" - style={{ width: 90 }} - /> - - ))} - - )} - - - - - - )} - + onRebooked={() => void refetch()} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 5c32622e9..6265d6f2d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -525,9 +525,10 @@ export default function TrainScheduleV2DetailPage() { b.originYardId === originYardId && !b.loadedAt && (b.loadingStatus ?? "UNLOADED") !== "LOADED" && + // Paid is read from the PAYMENT status only, never booking.status. (b.isGovernment - ? b.status === "APPROVED" || b.status === "PAID" - : b.status === "PAID" || + ? b.status === "APPROVED" || b.paymentStatus === "PAID" + : b.paymentStatus === "PAID" || // Shipping-line bookings ride from accept on the credit ledger. (Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")), ); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index f9857fa5b..e59851feb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() { const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); const [trainId, setTrainId] = useState(""); + // Voyage number for this departure — required. Auto-filled from the selected + // train's own voyage number (typed in the Train Builder) when a train is + // picked; legacy trains without one fall back to the direction-matched run + // number. Staff may edit. + const [voyageNumber, setVoyageNumber] = useState(""); const [reverseWagonOrder, setReverseWagonOrder] = useState(false); // "" = a normal customer train; an id dedicates the departure to that // shipping line and hides it from every customer-facing view. @@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() { }); return; } + if (!voyageNumber.trim()) { + toast({ + title: "Voyage number is required", + variant: "destructive", + }); + return; + } // Only build the window override when the toggle is on — off means "inherit // the global rules", which the API expresses as an absent windowRule. let windowRule: CreateScheduleWindowRulePayload | undefined; @@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() { routeId, scheduleDate: new Date(scheduleDate).toISOString(), trainId, + voyageNumber: voyageNumber.trim(), reverseWagonOrder, ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), ...(windowRule ? { windowRule } : {}), @@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() { }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); + setVoyageNumber(""); setReverseWagonOrder(false); setShippingLineCompanyId(""); setConfigureWindow(false); @@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() { }; })} value={trainId || null} - onChange={(v) => setTrainId(v ?? "")} + onChange={(v) => { + setTrainId(v ?? ""); + // Default the voyage number to the picked train's own voyage + // number (the Train Builder stores it as `trainName`). The run + // number is a train number, not a voyage — only fall back to it + // for legacy trains that have no voyage number yet; staff can + // still override. + const picked = (trainsQuery.data ?? []).find((t) => t.id === v); + const runNumber = + selectedRoute?.direction === "IMPORT" + ? picked?.importTrainNumber + : picked?.exportTrainNumber; + setVoyageNumber(picked?.trainName?.trim() || runNumber || ""); + }} searchable disabled={!routeId} nothingFoundMessage={ @@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() { : "Select a route first" } /> + setVoyageNumber(e.currentTarget.value)} + />