diff --git a/apps/edr-freight-api/src/migrations/3760000000000-PerWagonLoading.ts b/apps/edr-freight-api/src/migrations/3760000000000-PerWagonLoading.ts new file mode 100644 index 000000000..27267502a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3760000000000-PerWagonLoading.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon loading. Staff may confirm loading wagon-by-wagon instead of the + * whole booking at once: + * - bookings.loading_started_at — first wagon loaded; the booking stays PAID + * until every remaining wagon is LOADED (remaining = allocated − cancelled). + * Also shields a mid-load booking from the dispatch "left behind" unassign. + * - wagon_booking_allocations.loaded_at / loaded_by_user_id — per-wagon + * confirmation audit. + * - booking_wagon_cancellations.fault — who caused an at-loading cancel of + * the never-loaded remainder: CUSTOMER (fee applies) or EDR (no fee, credit + * rebookable in full). + */ +export class PerWagonLoading3760000000000 implements MigrationInterface { + name = 'PerWagonLoading3760000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS loading_started_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS loaded_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS unloaded_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS unloaded_by_user_id uuid`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_wagon_cancellations + ADD COLUMN IF NOT EXISTS fault varchar(16)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_wagon_cancellations DROP COLUMN IF EXISTS fault`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_by_user_id`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_by_user_id`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_at`, + ); + await queryRunner.query( + `ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_at`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS loading_started_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3770000000000-WagonAdjustmentReason.ts b/apps/edr-freight-api/src/migrations/3770000000000-WagonAdjustmentReason.ts new file mode 100644 index 000000000..da2075ad5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3770000000000-WagonAdjustmentReason.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Why a wagon left (or joined) the consist, on the adjustment log itself. + * + * SCHEDULED-run detach / send-to-maintenance now requires a reason instead of + * a second staffer's approval, so the reason has to read back where the change + * reads back: the train-builder History tab. Nullable — every other writer + * (trip cuts, couples, arrival returns) keeps logging without one. + */ +export class WagonAdjustmentReason3770000000000 implements MigrationInterface { + name = 'WagonAdjustmentReason3770000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + ADD COLUMN IF NOT EXISTS reason varchar(500)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + DROP COLUMN IF EXISTS reason`, + ); + } +} 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 a3ed3e417..f07e6d50f 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 @@ -939,8 +939,12 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); + // `toDocumentModel` reads the booking (route/wagons, PNR) straight off the + // data source for a booking-sourced invoice — a stub that answers "no such + // booking" keeps these summary assertions about the invoice itself. + const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) }; const service = new BillingService( - {} as never, + dataSource as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, { findAll: jest.fn().mockResolvedValue([]) } as never, {} as never, @@ -1014,6 +1018,34 @@ describe("BillingService.document", () => { expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + it("prints the provider transaction reference of a settled invoice", async () => { + const { service, render } = build( + invoiceRow({ + status: Freight.InvoiceStatus.Paid, + paidAmount: 100, + balanceAmount: 0, + payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }], + payment: { transactionId: "FT26082700123" }, + }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" }); + }); + + it("adds no transaction reference row to an unpaid invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect( + model.summary.find((r: { label: string }) => r.label === "Transaction ref"), + ).toBeUndefined(); + }); + it("calls render (not renderThermal) for the default format", async () => { const { service, render, renderThermal } = build(invoiceRow()); jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); 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 9dc6e15e9..cf41998ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -42,6 +42,7 @@ import { applySettlement, invoicePaymentMethodExpr, round2, + settlementReferences, } from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; @@ -268,7 +269,7 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -301,7 +302,9 @@ export class BillingService { }); } if (filter.sources?.length) { - qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources }); + qb.andWhere("invoice.source IN (:...sources)", { + sources: filter.sources, + }); } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { @@ -327,7 +330,9 @@ export class BillingService { }); } if (filter.issuedTo) { - qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo }); + qb.andWhere("invoice.issuedAt <= :issuedTo", { + issuedTo: filter.issuedTo, + }); } if (filter.dueFrom) { qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom }); @@ -590,21 +595,28 @@ export class BillingService { /** * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, * never through the gateway) and ETB invoices Finance settles by hand (bank - * transfer / counter) instead of the customer paying online. Open ones by - * default or a single status when filtered; both currencies unless - * `currency` narrows it. Booking-sourced rows carry the booking's reference, - * trade direction and pay-window deadline so the UI can show the countdown - * and link to the booking. + * transfer / counter) instead of the customer paying online. Both currencies + * unless `currency` narrows it, and only ones whose manual-payment channel is + * switched on. Open ones by default — pin `status` or `statuses` to widen + * that. Every other dimension is the invoice list's own (`applyInvoiceFilters` + * + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike. + * Booking-sourced rows carry the booking's reference, trade direction and + * pay-window deadline so the UI can show the countdown and link to the + * booking. */ async findOfflineUsdPaginated( - filter: { - status?: Freight.InvoiceStatus; - search?: string; - currency?: "USD" | "ETB"; + filter: InvoiceListFilters & { page?: number; pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } = {}, - ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> { + ): Promise<{ + items: OfflineUsdInvoiceRow[]; + total: number; + /** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */ + outstanding: Record; + }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -613,33 +625,75 @@ export class BillingService { // a row Finance cannot act on is noise, and the confirm endpoint would // refuse it anyway. All off → nothing to work. const enabled = await this.manualPaymentSettings.enabledCurrencies(); - if (!enabled.length) return { items: [], total: 0 }; - const currencies = filter.currency - ? enabled.filter((c) => c === filter.currency) - : enabled; - if (!currencies.length) return { items: [], total: 0 }; + const empty = { items: [], total: 0, outstanding: {} }; + if (!enabled.length) return empty; + const wanted = filter.currency?.toUpperCase(); + const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled; + if (!currencies.length) return empty; - const qb = this.dataSource - .getRepository(Invoice) - .createQueryBuilder("invoice") - .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) - .orderBy("invoice.issuedAt", "DESC") + /** + * The worklist narrows by the same vocabulary as the main invoice list, so + * both share `applyInvoiceFilters` — which references the `company` and + * `payment` aliases, hence the unconditional joins. `select` is false for + * the aggregate pass, where joined columns would break the GROUP BY. + */ + const buildQb = (select: boolean) => { + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice"); + if (select) { + qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect( + "invoice.payment", + "payment", + ); + } else { + qb.leftJoin("invoice.company", "company").leftJoin( + "invoice.payment", + "payment", + ); + } + qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies }); + // "What still needs settling" is the default cut, but only until the + // caller pins a status — either the single-status param or the filter + // bar's multi-select. + if (!filter.status && !filter.statuses?.length) { + qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); + } + // `currency` is already enforced by the enabled-currency IN above, and + // re-applying it would only repeat the same predicate. + this.applyInvoiceFilters(qb, { ...filter, currency: undefined }); + return qb; + }; + + const qb = buildQb(true) + // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated + // raw; the id tiebreaker keeps paging stable when the column ties. + .orderBy( + INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", + filter.sortOrder ?? "DESC", + ) + .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); - if (filter.status) { - qb.andWhere("invoice.status = :status", { status: filter.status }); - } else { - qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); - } - if (filter.search) { - qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", - { search: `%${filter.search}%` }, - ); - } const [rawItems, total] = await qb.getManyAndCount(); + + // Outstanding across the whole filtered set, not the visible page — the + // KPI must not change as Finance pages through the worklist. + const outstandingRows: { currency: string; outstanding: string }[] = + await buildQb(false) + .select("invoice.currency", "currency") + .addSelect("SUM(invoice.balanceAmount)", "outstanding") + .groupBy("invoice.currency") + .getRawMany(); + // Folded case-insensitively on the way out: stored casing has drifted + // ("usd" rows exist), so two groups can address the same currency. + const outstanding: Record = {}; + for (const row of outstandingRows) { + const key = (row.currency ?? "").toUpperCase(); + outstanding[key] = + (outstanding[key] ?? 0) + (Number(row.outstanding) || 0); + } const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items @@ -703,6 +757,7 @@ export class BillingService { } as OfflineUsdInvoiceRow; }), total, + outstanding, }; } @@ -849,7 +904,9 @@ export class BillingService { { label: "Wagons", value: - booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + booking.wagonsRequired != null + ? String(booking.wagonsRequired) + : null, }, ]; } @@ -919,11 +976,24 @@ export class BillingService { const eimsCfg = this.config.get("eims"); if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); if (eimsCfg?.invoice?.sellerVatNumber) { - summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + summary.push({ + label: "Seller VAT No.", + value: eimsCfg.invoice.sellerVatNumber, + }); } // MoR EIMS reference — only once actually registered, never a placeholder row. - if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + if (invoice.eimsIrn) + summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + + // The provider's transaction number for the money actually received — CBE's `FT…`, + // telebirr's receipt number, or the bank-slip reference a teller recorded manually. + // It is what a payer holding a receipt can match this invoice against, and what + // finance reconciles a bank statement with; without it a PAID invoice proves only + // that EDR says it was paid. `findById` already loads the `payment` relation, so both + // sources are in hand here — see settlementReferences for why both are read. + const txnRefs = settlementReferences(invoice); + if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs }); // PNR — the CBE_BILL reference the customer pays against, written onto the booking at // payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so @@ -933,7 +1003,8 @@ export class BillingService { where: { id: invoice.sourceId }, select: ["id", "pnrCode"], }); - if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode }); + if (booking?.pnrCode) + summary.push({ label: "PNR", value: booking.pnrCode }); } return { @@ -954,7 +1025,9 @@ export class BillingService { currency: l.currency, })), totals, - qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr + ? pngDataUrl(invoice.eimsSignedQr) + : null, }; } @@ -1213,7 +1286,9 @@ export class BillingService { metadata: l.metadata ?? null, })); - const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0)); + const total = round2( + lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0), + ); if (!(total > 0)) { throw new BadRequestException("A memo must have a positive total."); } @@ -1243,7 +1318,9 @@ export class BillingService { subtotalAmount: total, taxAmount: 0, totalAmount: total, - ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), + ...(settled + ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } + : {}), }, mg, code, @@ -1254,7 +1331,11 @@ export class BillingService { eimsReason: reason, relatedInvoiceId: original.id, ...(settled - ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() } + ? { + paidAmount: memo.totalAmount, + balanceAmount: 0, + paidAt: new Date(), + } : {}), }; await mg.update(Invoice, memo.id, patch); @@ -1314,7 +1395,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg, code); @@ -1835,9 +1916,9 @@ export class BillingService { dueAt, ...(issuing ? { - status: Freight.InvoiceStatus.Pending, - issuedAt: invoice.issuedAt ?? new Date(), - } + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } : {}), }; await mg.update(Invoice, { id: invoice.id }, patch); @@ -1901,10 +1982,7 @@ export class BillingService { const repo = this.dataSource.getRepository(Invoice); const invoices = await repo.findBy({ paymentId, - status: In([ - Freight.InvoiceStatus.Issued, - Freight.InvoiceStatus.Pending, - ]), + status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]), }); for (const invoice of invoices) { await repo.update( @@ -2081,7 +2159,10 @@ export class BillingService { // Same reference, for an ad-hoc additional charge — its own column, since // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking // can carry many of these at once. - if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + if ( + billReference && + invoice.source === Freight.InvoiceSource.AdditionalCharge + ) { await this.dataSource .getRepository(AdditionalCharge) .update({ id: invoice.sourceId }, { paymentReference: billReference }); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 78cea01b6..d85facc0f 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -337,7 +337,11 @@ export class InvoiceDocumentService { let y = 700; const colX = [36, 300]; const colW = 250; - model.summary.slice(0, 16).forEach((row, i) => { + // 20, not 16: a booking invoice already fills 16 rows with every optional one present + // (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the + // 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end + // at y=423, leaving the line-item table its full run down to the y<190 cut-off. + model.summary.slice(0, 20).forEach((row, i) => { const x = colX[i % 2]; if (i % 2 === 0 && i > 0) y -= 27; ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts new file mode 100644 index 000000000..2d10d6f4d --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts @@ -0,0 +1,50 @@ +import { settlementReferences } from "./invoice-settlement.util"; + +describe("settlementReferences", () => { + it("returns the provider reference recorded on the invoice ledger", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + }), + ).toBe("FT26082700123"); + }); + + it("reads the linked gateway payment row when the ledger has no reference", () => { + expect( + settlementReferences({ + payments: [{ reference: null }], + payment: { transactionId: "TB998877" }, + }), + ).toBe("TB998877"); + }); + + it("does not repeat a reference that both sources carry", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + payment: { transactionId: "FT26082700123" }, + }), + ).toBe("FT26082700123"); + }); + + it("lists every leg of a partially-then-fully paid invoice, oldest first", () => { + expect( + settlementReferences({ + payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }], + }), + ).toBe("SLIP-001, FT26082700123"); + }); + + it("drops the internal intent id the gateway path falls back to", () => { + expect( + settlementReferences({ + payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }], + }), + ).toBeNull(); + }); + + it("is null for an unpaid invoice", () => { + expect(settlementReferences({ payments: [] })).toBeNull(); + expect(settlementReferences({})).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index 172e74c17..994208229 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [ /** Settled at a gateway whose provider row is no longer linked. */ "GATEWAY", ] as const; + +/** Anything shaped enough to read settlement references off. */ +interface SettlementReferenceSource { + payments?: Array<{ reference?: string | null }> | null; + payment?: { transactionId?: string | null } | null; +} + +/** + * A settlement reference is the PROVIDER's own transaction number, never ours. + * The gateway path falls back to the intent id when a provider returns no txn + * ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a + * uuid — an internal correlation key that means nothing to a payer holding a + * bank slip, so it is dropped rather than printed. No provider's reference is + * uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings. + */ +const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Every provider transaction reference recorded against an invoice, oldest + * first, joined for display — CBE's `FT…`, telebirr's receipt number, or the + * bank-slip number a teller typed into a manual settlement. Null when nothing + * identifiable was recorded. + * + * Reads BOTH sources because neither alone is complete: the invoice's own + * ledger is the only record of manual settlements and of each leg of a + * partially-paid invoice, while the linked `freight.payments` row is the only + * place a provider txn id lands when it arrives after settlement (a webhook + * that stamps `transactionId` on an already-settled intent). Deduped, since + * the ordinary gateway path writes the same value to both. + */ +export function settlementReferences( + invoice: SettlementReferenceSource, +): string | null { + const refs = [ + ...(invoice.payments ?? []).map((p) => p.reference), + invoice.payment?.transactionId, + ].filter( + (ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string), + ); + return [...new Set(refs)].join(", ") || null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 07d4c4e06..66ee2cbbb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -1,6 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +import { + bulkTonWagonsRequired, + bulkTonsPerWagonFor, +} from '../train-scheduling/train-capacity.util'; /** * Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking @@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = ).rejects.toThrow(/already shares a wagon/i); }); }); + +/** + * A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and + * bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it + * alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking + * re-inflates to its pre-cancel count on the next allocation and each wagon + * carries tons / stale-count instead of the real even share. + */ +describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => { + // 980T over 14 wagons (70T each), 2 wagons cancelled. + const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 }; + const droppedWeight = 140; + const wagonsCancelled = 2; + + // The decrement applied in applyPaidCut's booking update. + const after = { + ...before, + cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight, + bulkRequestedWagons: Math.max( + 0, + Math.floor(before.bulkRequestedWagons - wagonsCancelled), + ), + }; + + it('reallocates at the reduced count, not the pre-cancel one', () => { + expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14); + expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12); + }); + + it('keeps tons-per-wagon at the real even share', () => { + // Stale count would spread 840T over 14 wagons → 60T each. + expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70); + }); + + it('cancelling every wagon leaves no requested count behind', () => { + const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14)); + expect(all).toBe(0); + expect(bulkTonWagonsRequired( + { ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all }, + undefined, + 'nw5', + 70, + )).toBe(0); + }); +}); + +/** + * Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook + * unless the DTO carries a wagon count (" is booked by wagons — enter + * the number of wagons needed"), and the quantities snapshot holds tons only. + * The count therefore has to come off the cancellation row itself. + */ +describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => { + const svc = Object.create(BookingWagonCancellationService.prototype) as { + buildRebookDto( + row: unknown, + scheduledDate: string, + overrides?: unknown, + ): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number }; + }; + + it('carries the cancelled wagon count onto the rebook', () => { + const dto = svc.buildRebookDto( + { wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } }, + '2026-09-10', + ); + expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]); + // Without this the create path throws before the booking is ever made. + expect(dto.requestedWagons).toBe(2); + }); + + it('rounds a fractional cut up to a whole wagon', () => { + const dto = svc.buildRebookDto( + { wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } }, + '2026-09-10', + ); + // Flooring would send 0 into a check that demands >= 1. + expect(dto.requestedWagons).toBe(1); + }); + + it('leaves the count off when nothing was cancelled', () => { + const dto = svc.buildRebookDto( + { wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } }, + '2026-09-10', + ); + expect(dto.requestedWagons).toBeUndefined(); + }); +}); 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 808421a0c..ac6fb5aa3 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 @@ -25,6 +25,8 @@ import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; @@ -35,6 +37,7 @@ import { } from './booking-wagon-cancellations.repository'; import { BookingsRepository } from './bookings.repository'; import { + CancelRemainingWagonsDto, RebookCancelledWagonsDto, RebookContainerLineDto, RequestWagonCancellationDto, @@ -625,7 +628,14 @@ export class BookingWagonCancellationService { this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`); return; } - if (row.status !== 'FEE_PENDING') return; + if (row.status !== 'FEE_PENDING') { + // At-loading cancels apply the cut immediately and leave the invoice + // open — settle only the payment stamp when the customer pays later. + if (!row.feePaidAt) { + await this.repo.update(row.id, { feePaidAt: new Date() }); + } + return; + } // The fee can settle after loading started (slow payment). Never cut // loaded cargo: leave the row FEE_PENDING and alert staff to resolve @@ -653,6 +663,25 @@ export class BookingWagonCancellationService { return; } + await this.applyCut(row, releasedEarly, { feeSettled: true }); + this.logger.log( + `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, + ); + } + + /** + * Apply the cut to the booking: reduce quantities/wagons/amount, release the + * cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee + * settlement for the customer-requested flow (feeSettled: true) and + * immediately for at-loading cancels (feeSettled only when no fee is owed — + * EDR fault; a customer-fault cut leaves feePaidAt null until the open + * invoice settles via onFeePaid). + */ + private async applyCut( + row: BookingWagonCancellation, + releasedEarly: boolean, + opts: { feeSettled: boolean }, + ): Promise { await this.dataSource.transaction(async (manager) => { const booking = await manager.getRepository(Booking).findOne({ where: { id: row.bookingId }, @@ -704,8 +733,21 @@ export class BookingWagonCancellationService { Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), ); const isFull = wagonsLeft <= 0; + // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which + // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the + // booking to its pre-cancel count on the next allocation (and shrinks + // tons-per-wagon to tons / stale-count), so shrink it with the cut. + const requestedWagonsLeft = booking.bulkRequestedWagons + ? Math.max( + 0, + Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)), + ) + : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), + ...(requestedWagonsLeft !== null + ? { bulkRequestedWagons: requestedWagonsLeft } + : {}), cargoTotalWeightVgm: Math.max( 0, round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), @@ -723,7 +765,7 @@ export class BookingWagonCancellationService { await manager.getRepository(BookingWagonCancellation).update(row.id, { status: 'CREDIT_AVAILABLE', - feePaidAt: new Date(), + ...(opts.feeSettled ? { feePaidAt: new Date() } : {}), weightTons: droppedWeight, cancelledQuantities: quantities, }); @@ -741,9 +783,145 @@ export class BookingWagonCancellationService { : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`, ); } - this.logger.log( - `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, + } + + /** + * Staff cancel of the never-loaded remainder mid-load: the operator loaded + * what physically rides and cuts the rest, so the booking shrinks to its + * loaded wagons, dispatch unblocks, and the warehouse only ever sees the + * final (smaller) booking. Unlike the customer flow the cut applies + * IMMEDIATELY — the train cannot wait for a fee payment: + * - CUSTOMER fault: cancellation fee invoiced, payable after; the credit + * row opens right away (feePaidAt stamps when the invoice settles). + * - EDR fault: no fee at all; the credit is rebookable in full. + */ + async cancelRemainingAtLoading( + bookingId: string, + dto: CancelRemainingWagonsDto, + userId?: string, + ): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`); + if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') { + throw new BadRequestException( + 'Only a paid booking still loading can cancel its remaining wagons.', + ); + } + if (booking.loadedAt) { + throw new BadRequestException( + 'This booking is already fully loaded — there is nothing left to cancel.', + ); + } + if (!booking.contractId) { + throw new BadRequestException( + 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', + ); + } + const open = await this.repo.findOpenForBooking(bookingId); + if (open) { + throw new ConflictException( + 'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.', + ); + } + + const allocations = await this.dataSource + .getRepository(WagonBookingAllocation) + .createQueryBuilder('alloc') + .innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id') + .innerJoin( + TrainSchedule, + 'schedule', + 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', + { scheduleId: dto.scheduleId }, + ) + .where('alloc.booking_id = :bookingId', { bookingId }) + .getMany(); + const loaded = allocations.filter( + (a) => a.status === 'LOADED' || a.status === 'DEPARTED', ); + const remaining = allocations.filter( + (a) => a.status !== 'LOADED' && a.status !== 'DEPARTED', + ); + if (!loaded.length) { + throw new BadRequestException( + 'Loading has not started for this booking — use the normal wagon cancellation flow.', + ); + } + if (!remaining.length) { + throw new BadRequestException( + 'Every wagon of this booking is loaded — there is nothing to cancel.', + ); + } + + const cut = await this.resolveRequestedCut(booking, { + wagonAllocationIds: remaining.map((r) => r.id), + } as RequestWagonCancellationDto); + if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); + + const edrFault = !!dto.edrFault; + const fee = edrFault ? null : await this.priceFee(booking, cut); + const creditAmount = this.creditFor(booking, cut.wagons); + + const row = await this.repo.create({ + bookingId, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: cut.quantities, + creditAmount, + feeRateId: fee?.rates[0]?.id ?? null, + feeAmount: fee?.amount ?? 0, + feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB', + status: 'FEE_PENDING', + reason: dto.reason, + fault: edrFault ? 'EDR' : 'CUSTOMER', + requestedByUserId: userId ?? null, + }); + + let current = row; + if (fee && fee.amount > 0) { + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: WAGON_CANCEL_FEE_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: fee.currency, + lines: [ + { + chargeType: 'CANCELLATION_FEE', + description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`, + quantity: cut.wagons, + unitRate: fee.perWagon, + amount: fee.amount, + currency: fee.currency, + metadata: { wagonCancellationId: row.id }, + }, + ], + totalAmount: fee.amount, + status: Freight.InvoiceStatus.Issued, + }); + current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row; + } + + // The cut applies NOW — booking shrinks, allocations release, credit opens. + // EDR fault (or a zero fee) settles the fee side immediately; a customer- + // fault fee stays owed and stamps feePaidAt via onFeePaid when it settles. + await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 }); + + // The booking now holds only loaded wagons — let the journey complete the + // load (PAID → IN_TRANSIT, warehouse inventory, milestones). + this.events.emit('booking.wagonsCancelledAtLoading', { + bookingId, + scheduleId: dto.scheduleId, + userId: userId ?? null, + }); + + this.notifyStaff( + booking, + 'Wagons cancelled at loading', + `${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`, + ); + return this.mustFind(row.id); } /** @@ -1712,6 +1890,14 @@ export class BookingWagonCancellationService { } dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }]; + // NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create + // path rejects the rebook outright without it. The count is not in the + // quantities snapshot (which only carries tons) — it is the cancellation's + // own wagonsCancelled, so every existing credit rebooks without a backfill. + // Rounded UP: a fractional cut still needs a whole wagon to ride on, and + // flooring 0.5 would send 0 into a check that demands >= 1. + const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0)); + if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons; return dto; } 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 c76655240..9655a9603 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se import { FilterWagonCancellationsDto, RebookCancelledWagonsDto, + CancelRemainingWagonsDto, RequestWagonCancellationDto, } from "./dto/wagon-cancellation.dto"; import { @@ -640,6 +641,20 @@ export class BookingsController { return this.wagonCancellationService.requestCancellation(id, dto, user?.id); } + @Post(":id/wagon-cancellations/at-loading") + @BookingStaff(FREIGHT_PERMS.trainScheduling.load) + @ApiOperation({ + summary: + "Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.", + }) + async cancelRemainingWagonsAtLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelRemainingWagonsDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id); + } + @Get(":id/wagon-cancellations") @ApiOperation({ summary: "Wagon-cancellation history of one booking (owner or staff)", diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 631e78109..f86c3e4bf 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -3,9 +3,11 @@ import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, + IsBoolean, IsDateString, IsIn, IsInt, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto { @Min(1) pageSize?: number; } + +/** + * Staff cancel of the never-loaded remainder of a booking mid-load: everything + * not yet LOADED on the schedule is cut, the booking shrinks to its loaded + * wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER + * — cancellation fee invoiced (payable after the cut); EDR — no fee. + */ +export class CancelRemainingWagonsDto { + @ApiProperty({ description: 'Schedule the booking is being loaded on' }) + @IsUUID('4') + scheduleId!: string; + + @ApiProperty({ description: 'Why the remaining wagons are not riding' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + reason!: string; + + @ApiPropertyOptional({ + description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged', + }) + @IsOptional() + @IsBoolean() + edrFault?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts index 950be4575..f0de8831e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity { @Column({ name: 'reason', type: 'text', nullable: true }) reason?: string | null; + /** + * At-loading cancels of the never-loaded remainder: who caused it. + * CUSTOMER — cancellation fee applies (invoice payable after the cut); + * EDR — no fee, the full credit is rebookable. Null for customer-requested + * cancellations (the pre-loading flow). + */ + @Column({ name: 'fault', type: 'varchar', length: 16, nullable: true }) + fault?: 'CUSTOMER' | 'EDR' | null; + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) requestedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 67a486068..b86e1c3f3 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -588,6 +588,15 @@ export class Booking extends BaseEntity { @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) loadedAt?: Date | null; + /** + * First wagon of this booking confirmed loaded (per-wagon loading). The + * booking stays PAID until every remaining wagon is LOADED — loadedAt then + * stamps the completion. Also shields the booking from the dispatch + * "left behind" unassign while mid-load. + */ + @Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true }) + loadingStartedAt?: Date | null; + @Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true }) loadedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index e289674b2..5b5e73021 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository { .leftJoinAndSelect('routes.destinationYard', 'routeDestination') .leftJoinAndSelect('contract.cargoScope', 'cargoScope') .leftJoinAndSelect('cargoScope.cargoType', 'cargoType') + // Wagon types carry the rated capacity the booking forms need to reject + // a wagon count whose even share overloads a wagon (see maxTonsPerWagon). + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes') .leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots') .leftJoinAndSelect('contract.signatures', 'signatures') .leftJoinAndSelect('signatures.signatureFile', 'signatureFile') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index bfbc5675f..6c5390fee 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types'; // import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; @@ -858,6 +860,29 @@ export class ContractsService { ); } + // NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take + // so they can reject a wagon count whose even share overloads a wagon — + // the client-side twin of ContractBookingService.assertWagonShareFits. + // The raw wagonTypes join rows are dropped: only the derived cap ships. + for (const scope of contract.cargoScope ?? []) { + const cargoType = scope.cargoType as + | (CargoType & { maxTonsPerWagon?: number | null }) + | null + | undefined; + if (!cargoType) continue; + const allowed = (cargoType.wagonTypes ?? []).filter( + (wt) => Number(wt.capacityTons) > 0, + ); + cargoType.maxTonsPerWagon = allowed.length + ? Math.max( + ...allowed.map((wt) => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)), + ), + ) + : null; + delete cargoType.wagonTypes; + } + // Surface the staff "request changes" note so the portal can show the // customer what to fix. Degrade to null on lookup failure — a missing note // must never 500 a contract fetch. diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 103f4c2b7..c6bf45dfa 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity { @Column({ name: 'yard_id', type: 'uuid', nullable: true }) yardId!: string | null; + /** + * Why the wagon left/joined the consist. Required by the builder for a + * detach or maintenance move on a SCHEDULED run (that reason replaced the + * old second-staff approval); null for every other adjustment. + */ + @Column({ name: 'reason', type: 'varchar', length: 500, nullable: true }) + reason?: string | null; + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) occurredAt!: Date; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts index 6bec9f74e..5cb2002de 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity { @Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true }) confirmedByUserId?: string | null; + /** Per-wagon loading confirmation (status LOADED) — when and by whom. */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true }) + loadedByUserId?: string | null; + + /** Per-wagon unloading confirmation (status DEPARTED) — when and by whom. */ + @Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true }) + unloadedAt?: Date | null; + + @Column({ name: 'unloaded_by_user_id', type: 'uuid', nullable: true }) + unloadedByUserId?: string | null; + @OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation) containerItems?: WagonAllocationContainerItem[]; } 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 c7256d82d..07de05853 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 @@ -5,7 +5,7 @@ import { NotFoundException, Optional, } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; @@ -72,6 +72,117 @@ export class BookingJourneyService { async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) { const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + await this.assertBookingLoadable(schedule, booking); + return this.completeLoad(schedule, booking, userId ?? null); + } + + /** + * Confirm ONE wagon of the booking loaded (per-wagon loading). The booking + * stays PAID while wagons remain; loading the last remaining wagon runs the + * whole-booking completion (IN_TRANSIT, warehouse inventory, GRN, + * milestones) exactly as the one-shot load does. Wagons that will NOT ride + * must be cancelled via the at-loading cancellation before the booking can + * complete (and before the train may dispatch). + */ + async loadWagon( + scheduleId: string, + bookingId: string, + allocationId: string, + userId?: string | null, + ) { + const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + await this.assertBookingLoadable(schedule, booking); + const allocations = await this.allocationsForBooking( + this.dataSource.manager, + scheduleId, + bookingId, + ); + if (!allocations.length) { + throw new BadRequestException( + 'This booking has no wagon allocations on the schedule — use the whole-booking load.', + ); + } + const target = allocations.find((a) => a.id === allocationId); + if (!target) { + throw new NotFoundException('Wagon allocation not found on this booking/schedule'); + } + if (target.status === 'LOADED' || target.status === 'DEPARTED') { + throw new BadRequestException('This wagon is already loaded'); + } + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WagonBookingAllocation).update(target.id, { + status: 'LOADED', + loadedAt: now, + loadedByUserId: userId ?? null, + }); + if (!booking.loadingStartedAt) { + await manager + .getRepository(Booking) + .update(bookingId, { loadingStartedAt: now } as never); + } + // PARTIAL keeps the dispatch-readiness badge honest; completion below + // flips it to LOADED. + await manager + .getRepository(TrainScheduleBooking) + .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' }); + }); + + const remaining = allocations.filter( + (a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED', + ).length; + if (remaining === 0) { + const done = await this.completeLoad(schedule, booking, userId ?? null); + return { + ...done, + allocationId, + loadedWagons: allocations.length, + totalWagons: allocations.length, + completed: true, + }; + } + return { + bookingId, + allocationId, + status: booking.status, + loadedWagons: allocations.length - remaining, + totalWagons: allocations.length, + completed: false, + }; + } + + /** + * The at-loading cancel shrank the booking to its loaded wagons — if every + * wagon left on it is LOADED, the load is complete: run the whole-booking + * completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading. + */ + @OnEvent('booking.wagonsCancelledAtLoading') + async onWagonsCancelledAtLoading(payload: { + bookingId: string; + scheduleId: string; + userId?: string | null; + }): Promise { + try { + const allocations = await this.allocationsForBooking( + this.dataSource.manager, + payload.scheduleId, + payload.bookingId, + ); + const loaded = allocations.filter( + (a) => a.status === 'LOADED' || a.status === 'DEPARTED', + ).length; + if (!allocations.length || loaded < allocations.length) return; + await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId); + } catch (err) { + this.logger.error( + `Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** The pre-load gates shared by whole-booking and per-wagon loading. */ + private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise { if (booking.loadedAt || booking.status === 'IN_TRANSIT') { throw new BadRequestException('Booking is already loaded'); } @@ -86,6 +197,16 @@ export class BookingJourneyService { // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. await assertExportReceivedWithGrn(this.dataSource, booking); + } + + /** The whole-booking load side effects — gates already passed. */ + private async completeLoad( + schedule: TrainSchedule, + booking: Booking, + userId: string | null, + ) { + const scheduleId = schedule.id; + const bookingId = booking.id; // Direct truck-to-train cargo never sees the warehouse, so loading IS its // handover moment — the carriage acceptance sheet must go out to the // customer right here, not on a receive event that will never fire. @@ -165,6 +286,77 @@ export class BookingJourneyService { async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) { const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + await this.assertBookingUnloadable(schedule, booking); + return this.completeUnload(schedule, booking, userId ?? null); + } + + /** + * Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking + * only while wagons remain on the train — the booking stays IN_TRANSIT; + * unloading the last wagon runs the whole-booking completion (ARRIVED/ + * COMPLETED, wagon settlement, events) exactly as the one-shot unload does. + */ + async unloadWagon( + scheduleId: string, + bookingId: string, + allocationId: string, + userId?: string | null, + ) { + const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + await this.assertBookingUnloadable(schedule, booking); + const allocations = await this.allocationsForBooking( + this.dataSource.manager, + scheduleId, + bookingId, + ); + if (!allocations.length) { + throw new BadRequestException( + 'This booking has no wagon allocations on the schedule — use the whole-booking unload.', + ); + } + const target = allocations.find((a) => a.id === allocationId); + if (!target) { + throw new NotFoundException('Wagon allocation not found on this booking/schedule'); + } + if (target.status === 'DEPARTED') { + throw new BadRequestException('This wagon is already unloaded'); + } + + const now = new Date(); + await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, { + status: 'DEPARTED', + unloadedAt: now, + unloadedByUserId: userId ?? null, + }); + + const remaining = allocations.filter( + (a) => a.id !== target.id && a.status !== 'DEPARTED', + ).length; + if (remaining === 0) { + const done = await this.completeUnload(schedule, booking, userId ?? null); + return { + ...done, + allocationId, + unloadedWagons: allocations.length, + totalWagons: allocations.length, + completed: true, + }; + } + return { + bookingId, + allocationId, + status: booking.status, + unloadedWagons: allocations.length - remaining, + totalWagons: allocations.length, + completed: false, + }; + } + + /** The pre-unload gates shared by whole-booking and per-wagon unloading. */ + private async assertBookingUnloadable( + schedule: TrainSchedule, + booking: Booking, + ): Promise { if (booking.status !== 'IN_TRANSIT') { throw new BadRequestException( `Booking must be loaded/in transit before unloading (currently ${booking.status})`, @@ -173,7 +365,16 @@ export class BookingJourneyService { await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading'); await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); + } + /** The whole-booking unload side effects — gates already passed. */ + private async completeUnload( + schedule: TrainSchedule, + booking: Booking, + userId: string | null, + ) { + const scheduleId = schedule.id; + const bookingId = booking.id; // Intercity has no clearance/delivery tail — unloading completes it. Import/ // export continue into clearance, keyed on the booking's own arrival. const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED'; @@ -498,7 +699,17 @@ export class BookingJourneyService { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (booking.trainScheduleId !== scheduleId) { - throw new BadRequestException('Booking is not assigned to this schedule'); + // The schedule↔booking LINK is the same authority the workspace list + // (listYardWork) renders from — some flows (export train pick) create it + // with wagon allocations before bookings.train_schedule_id is stamped. + // Trusting only the column made those rows show a Load button that + // always 400'd. + const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({ + where: { trainScheduleId: scheduleId, bookingId }, + }); + if (!linked) { + throw new BadRequestException('Booking is not assigned to this schedule'); + } } return { schedule, booking }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index efa08a927..fa32b601b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -707,6 +707,46 @@ export class TrainSchedulingController { return this.bookingJourneyService.unloadBooking(id, bookingId); } + @Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load") + @TrainSchedulingLoad() + @ApiOperation({ + summary: + "Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.", + }) + loadScheduleBookingWagon( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @Param("allocationId", ParseUUIDPipe) allocationId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.loadWagon( + id, + bookingId, + allocationId, + resolveAuthUserId(user), + ); + } + + @Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload") + @TrainSchedulingUnload() + @ApiOperation({ + summary: + "Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.", + }) + unloadScheduleBookingWagon( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @Param("allocationId", ParseUUIDPipe) allocationId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingJourneyService.unloadWagon( + id, + bookingId, + allocationId, + resolveAuthUserId(user), + ); + } + @Post("schedules/:id/intercity/:bookingId/load") @TrainSchedulingLoad() @ApiOperation({ 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 71153c547..858d14c90 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 @@ -2946,6 +2946,10 @@ export class TrainSchedulingService { // The yard plan this departure was SOLD against must match where the steel // actually stands: a wagon sold from Dire but still in Mojo cannot board. await this.assertPlannedYardsAligned(schedule); + // Per-wagon loading: a booking mid-load is neither ridable nor removable — + // every wagon must be LOADED, or the never-loaded remainder cancelled + // (at-loading cancellation), before the train departs. + await this.assertNoPartiallyLoadedBookings(schedule); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); @@ -3144,6 +3148,42 @@ export class TrainSchedulingService { * PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay * (their charge sits on the credit ledger) yet ride from accept. */ + /** + * Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and + * SOME still PLANNED/RESERVED must resolve before departure — load the rest + * or cancel it (which shrinks the booking to its loaded wagons). Blocking + * here beats silently unassigning: unassign would delete LOADED allocations + * and strand cargo that is physically on the train. + */ + private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise { + if (!schedule.trainSetId) return; + const rows: Array<{ reference: string; loaded: string; total: string }> = + await this.dataSource.query( + `SELECT b.reference, + COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded, + COUNT(*) AS total + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.bookings b ON b.id = a.booking_id + WHERE tsw.train_set_id = $1 + AND a.deleted_at IS NULL + AND tsw.deleted_at IS NULL + AND b.deleted_at IS NULL + GROUP BY b.id, b.reference + HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 + AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, + [schedule.trainSetId], + ); + if (rows.length) { + const detail = rows + .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) + .join(', '); + throw new BadRequestException( + `Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`, + ); + } + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -3157,6 +3197,7 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL + AND b.loading_started_at IS NULL AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND b.is_government = false AND (b.status = 'PAID' diff --git a/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts index 269c4f9b4..69fc02f4e 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts @@ -1,15 +1,16 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; export class SendWagonToMaintenanceDto { - @ApiPropertyOptional({ + @ApiProperty({ description: - "Why the wagon is going to maintenance. Stored on the wagon's status-history " + - 'log alongside the train it was detached from, matching the fleet desk flow.', + "Why the wagon is going to maintenance — required. Stored on the wagon's " + + 'status-history log alongside the train it was detached from, and on the ' + + "train's wagon-adjustment history.", maxLength: 500, }) - @IsOptional() @IsString() + @IsNotEmpty() @MaxLength(500) - note?: string; + note!: string; } diff --git a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts index afe219bfa..fcc6f84e7 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts @@ -1,18 +1,14 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; -import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity'; - -export class CreateWagonDetachRequestDto { +/** + * Detach a wagon from the consist. The reason is always required — it is + * recorded both as an auto-approved wagon_detach_requests audit row and on the + * train's wagon-adjustment history (the History tab). + */ +export class DetachWagonDto { @ApiProperty({ - enum: WagonDetachRequestAction, - description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.', - }) - @IsEnum(WagonDetachRequestAction) - action!: WagonDetachRequestAction; - - @ApiProperty({ - description: 'Why the wagon must leave the scheduled consist. Shown to the approver.', + description: 'Why the wagon leaves the consist — required.', maxLength: 500, }) @IsString() @@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto { @MaxLength(500) reason!: string; } - -export class DecideWagonDetachRequestDto { - @ApiPropertyOptional({ - description: 'Decision note — required when rejecting, optional when approving.', - maxLength: 500, - }) - @IsOptional() - @IsString() - @MaxLength(500) - note?: string; -} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 46c95de95..8e8c894c0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -25,10 +25,7 @@ import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; -import { - CreateWagonDetachRequestDto, - DecideWagonDetachRequestDto, -} from './dto/wagon-detach-request.dto'; +import { DetachWagonDto } from './dto/wagon-detach-request.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, - FREIGHT_PERMS.trains.approveWagonDetach, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -185,29 +181,38 @@ export class TrainBuilderController { @Delete(':id/wagons/:wagonId') @FleetManage(FREIGHT_PERMS.trains.assignWagons) - @ApiOperation({ summary: 'Detach one wagon from the consist' }) + @ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' }) removeWagon( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, @CurrentUser() user: AuthUserPayload, + @Body() dto: DetachWagonDto, ) { - return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user)); + return this.trainBuilderService.removeWagon( + id, + wagonId, + resolveAuthUserId(user), + dto.reason, + ); } @Post(':id/wagons/:wagonId/maintenance') @FleetManage(FREIGHT_PERMS.trains.assignWagons) - @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) + @ApiOperation({ + summary: + 'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required', + }) sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, @CurrentUser() user: AuthUserPayload, - @Body() dto?: SendWagonToMaintenanceDto, + @Body() dto: SendWagonToMaintenanceDto, ) { return this.trainBuilderService.sendWagonToMaintenance( id, wagonId, resolveAuthUserId(user), - dto?.note, + dto.note, ); } @@ -220,65 +225,6 @@ export class TrainBuilderController { return this.trainBuilderService.listDetachRequests(id); } - @Post(':id/wagons/:wagonId/detach-requests') - @FleetManage(FREIGHT_PERMS.trains.assignWagons) - @ApiOperation({ - summary: - 'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run', - }) - createDetachRequest( - @Param('id', ParseUUIDPipe) id: string, - @Param('wagonId', ParseUUIDPipe) wagonId: string, - @Body() dto: CreateWagonDetachRequestDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.trainBuilderService.createDetachRequest( - id, - wagonId, - dto, - resolveAuthUserId(user), - ); - } - - @Post(':id/detach-requests/:requestId/approve') - @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) - @ApiOperation({ - summary: - 'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester', - }) - approveDetachRequest( - @Param('id', ParseUUIDPipe) id: string, - @Param('requestId', ParseUUIDPipe) requestId: string, - @CurrentUser() user: AuthUserPayload, - @Body() dto?: DecideWagonDetachRequestDto, - ) { - return this.trainBuilderService.decideDetachRequest( - id, - requestId, - 'APPROVE', - resolveAuthUserId(user), - dto?.note, - ); - } - - @Post(':id/detach-requests/:requestId/reject') - @FleetManage(FREIGHT_PERMS.trains.approveWagonDetach) - @ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' }) - rejectDetachRequest( - @Param('id', ParseUUIDPipe) id: string, - @Param('requestId', ParseUUIDPipe) requestId: string, - @CurrentUser() user: AuthUserPayload, - @Body() dto: DecideWagonDetachRequestDto, - ) { - return this.trainBuilderService.decideDetachRequest( - id, - requestId, - 'REJECT', - resolveAuthUserId(user), - dto.note, - ); - } - @Post(':id/reorder-wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.detach-reason.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.detach-reason.spec.ts new file mode 100644 index 000000000..bebb9b111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.detach-reason.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException } from '@nestjs/common'; + +import { TrainBuilderService } from './train-builder.service'; +import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity'; + +/** + * Every detach / send-to-maintenance carries a reason — scheduled run or not + * (the reason replaced the old second-staff approval). The recorder rejects a + * missing/blank one before anything is written, and stores a trimmed, capped + * copy on the audit row otherwise. + */ +describe('TrainBuilderService — detach reason is always required', () => { + const svc = Object.create(TrainBuilderService.prototype) as { + recordDetachReason( + manager: unknown, + trainId: string, + wagonId: string, + action: WagonDetachRequestAction, + reason: string | null | undefined, + userId?: string | null, + ): Promise; + }; + + /** Minimal EntityManager: records what the recorder would persist. */ + const managerSpy = () => { + const saved: Array> = []; + return { + saved, + getRepository: (entity: { name: string }) => + entity.name === 'Wagon' + ? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) } + : { + create: (row: Record) => row, + save: async (row: Record) => { + saved.push(row); + return row; + }, + }, + }; + }; + + it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => { + const manager = managerSpy(); + await expect( + svc.recordDetachReason( + manager, + 'train-1', + 'wagon-1', + WagonDetachRequestAction.Detach, + reason, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + // Nothing is written when the reason is missing. + expect(manager.saved).toHaveLength(0); + }); + + it('records the reason as an auto-approved audit row', async () => { + const manager = managerSpy(); + await svc.recordDetachReason( + manager, + 'train-1', + 'wagon-1', + WagonDetachRequestAction.Maintenance, + ' Brake shoe worn through ', + 'user-1', + ); + expect(manager.saved).toHaveLength(1); + const row = manager.saved[0]; + expect(row).toMatchObject({ + trainId: 'train-1', + wagonId: 'wagon-1', + wagonNumber: 'NW5-0412', + action: WagonDetachRequestAction.Maintenance, + reason: 'Brake shoe worn through', + // No second person: the actor is both requester and decider. + status: 'APPROVED', + requestedBy: 'user-1', + decidedBy: 'user-1', + }); + }); + + it('caps an over-long reason at the column width', async () => { + const manager = managerSpy(); + await svc.recordDetachReason( + manager, + 'train-1', + 'wagon-1', + WagonDetachRequestAction.Detach, + 'x'.repeat(900), + null, + ); + expect(String(manager.saved[0].reason)).toHaveLength(500); + }); +}); 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 09f6559f7..7b28a8582 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 @@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; -import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; import { @@ -253,6 +252,7 @@ export class TrainBuilderService { yardLabel: string | null; actor: string | null; scheduleReference: string | null; + reason: string | null; occurredAt: Date; }>, ] = await Promise.all([ @@ -270,6 +270,7 @@ export class TrainBuilderService { COALESCE(y.label, y.code) AS "yardLabel", COALESCE(u.username, u.email) AS "actor", ts.reference AS "scheduleReference", + l.reason, l.occurred_at AS "occurredAt" FROM freight.schedule_wagon_adjustment_logs l LEFT JOIN freight.yards y ON y.id = l.yard_id @@ -491,8 +492,9 @@ export class TrainBuilderService { : null, }, activeSchedules: schedules, - // Composition is frozen while the train is out on a dispatched run. - editable: !schedules.some((s) => s.status === 'DISPATCHED'), + // The built train is always editable — dispatched/arrived runs render from + // their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs. + editable: true, }; } @@ -754,22 +756,40 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Detach one wagon and close the sequence gap it leaves. */ - async removeWagon(id: string, wagonId: string, userId?: string | null) { + /** + * Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run + * the detach still executes directly, but a reason is required and recorded + * in wagon_detach_requests (auto-approved) — the audit trail without the + * former second-staff approval step. + */ + async removeWagon( + id: string, + wagonId: string, + userId?: string | null, + reason?: string | null, + ) { const pending = await this.dataSource.transaction(async (manager) => { - await this.assertDetachNeedsNoApproval(manager, id); - return this.removeWagonCore(manager, id, wagonId, userId); + await this.recordDetachReason( + manager, + id, + wagonId, + WagonDetachRequestAction.Detach, + reason, + userId, + ); + return this.removeWagonCore(manager, id, wagonId, userId, reason); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } - /** Transactional body of removeWagon — also runs under an approved detach request. */ + /** Transactional body of removeWagon — `reason` rides into the history log. */ private async removeWagonCore( manager: EntityManager, id: string, wagonId: string, userId?: string | null, + reason?: string | null, ): Promise { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -791,6 +811,7 @@ export class TrainBuilderService { [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], userId ?? null, wagon.currentYardId ?? train.currentYardId ?? null, + reason, ); } @@ -805,8 +826,16 @@ export class TrainBuilderService { userId?: string | null, note?: string | null, ) { + // `note` is the required reason — recordDetachReason rejects it empty. const pending = await this.dataSource.transaction(async (manager) => { - await this.assertDetachNeedsNoApproval(manager, id); + await this.recordDetachReason( + manager, + id, + wagonId, + WagonDetachRequestAction.Maintenance, + note, + userId, + ); return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note); }); await this.reconcileWindowAfterConsistChange(pending); @@ -883,96 +912,54 @@ export class TrainBuilderService { [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], userId ?? null, yardId, + note, ); } } /** - * Direct-detach guard: while this train carries a live SCHEDULED run, - * removing a wagon changes a departure customers already booked against, so - * it is a two-person action — refuse here and point at the request flow. - * DRAFT stays freely editable; DISPATCHED is already frozen by - * getEditableTrain (the train is IN_SERVICE). + * Every detach / send-to-maintenance carries a REASON — scheduled run or + * not — and an auto-approved wagon_detach_requests row records who did it + * and why (the audit trail that replaced the former second-staff approval). + * DISPATCHED trains never reach here: getEditableTrain freezes them. */ - private async assertDetachNeedsNoApproval( + private async recordDetachReason( manager: EntityManager, trainId: string, - ): Promise { - const scheduled = await this.findScheduledRun(manager, trainId); - if (scheduled) { - throw new ConflictException( - `Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`, - ); - } - } - - private async findScheduledRun( - manager: EntityManager, - trainId: string, - ): Promise<{ id: string; reference: string | null } | null> { - const rows: { id: string; reference: string | null }[] = await manager.query( - `SELECT ts.id, ts.reference - FROM freight.train_schedules ts - JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE tset.train_id = $1 - AND ts.status = 'SCHEDULED' - AND ts.deleted_at IS NULL - LIMIT 1`, - [trainId], - ); - return rows[0] ?? null; - } - - /** - * File a detach/maintenance approval request for a wagon on a SCHEDULED - * train. The request carries the reason; a different staffer with - * trains:approve_wagon_detach decides it (approval executes the detach). - */ - async createDetachRequest( - id: string, wagonId: string, - dto: CreateWagonDetachRequestDto, + action: WagonDetachRequestAction, + reason: string | null | undefined, userId?: string | null, - ) { - return this.dataSource.transaction(async (manager) => { - const train = await this.getEditableTrain(manager, id); - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); - if (!wagon || wagon.trainId !== train.id) { - throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); - } - const scheduled = await this.findScheduledRun(manager, train.id); - if (!scheduled) { - throw new ConflictException( - 'This train has no SCHEDULED run — detach the wagon directly, no approval needed', - ); - } - // Refuse up front what an approval could never execute (booked - // allocations pin the wagon) — but release nothing yet: slots are only - // touched when the approved detach actually runs. - await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true }); - const repo = manager.getRepository(WagonDetachRequest); - const open = await repo.findOne({ - where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending }, - }); - if (open) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} already has a pending detach request`, - ); - } - return repo.save( - repo.create({ - trainId: train.id, - wagonId: wagon.id, - wagonNumber: wagon.wagonNumber, - action: dto.action, - reason: dto.reason.trim(), - requestedBy: userId ?? null, - }), + ): Promise { + const trimmed = reason?.trim(); + if (!trimmed) { + throw new BadRequestException( + `Give a reason for ${ + action === WagonDetachRequestAction.Maintenance + ? 'sending this wagon to maintenance' + : 'detaching this wagon' + }`, ); - }); + } + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + const repo = manager.getRepository(WagonDetachRequest); + const now = new Date(); + await repo.save( + repo.create({ + trainId, + wagonId, + wagonNumber: wagon?.wagonNumber ?? wagonId, + action, + reason: trimmed.slice(0, 500), + status: WagonDetachRequestStatus.Approved, + requestedBy: userId ?? null, + decidedBy: userId ?? null, + decidedAt: now, + }), + ); } - /** All detach/maintenance requests of this train, newest first — the approval audit trail. */ + /** All detach/maintenance records of this train, newest first — the audit trail. */ async listDetachRequests(trainId: string) { const rows: Array<{ id: string; @@ -1012,68 +999,6 @@ export class TrainBuilderService { return rows; } - /** - * Decide a pending request. Approve executes the detach (or maintenance - * move) in the same transaction that stamps the decision, so an approved row - * can never exist without its detach having happened. The requester cannot - * approve their own request; a rejection must carry a note. - */ - async decideDetachRequest( - id: string, - requestId: string, - decision: 'APPROVE' | 'REJECT', - userId?: string | null, - note?: string | null, - ) { - const pending = await this.dataSource.transaction(async (manager) => { - const repo = manager.getRepository(WagonDetachRequest); - const request = await repo.findOne({ - where: { id: requestId, trainId: id }, - lock: { mode: 'pessimistic_write' }, - }); - if (!request) { - throw new NotFoundException(`Detach request ${requestId} not found on this train`); - } - if (request.status !== WagonDetachRequestStatus.Pending) { - throw new ConflictException( - `This request was already ${request.status.toLowerCase()}`, - ); - } - const decisionNote = note?.trim() || null; - if (decision === 'REJECT') { - if (!decisionNote) { - throw new BadRequestException('A note explaining the rejection is required'); - } - await repo.update(request.id, { - status: WagonDetachRequestStatus.Rejected, - decidedBy: userId ?? null, - decidedAt: new Date(), - decisionNote, - }); - return null; - } - // The 4-eyes point of the gate: requester and approver are different people. - if (request.requestedBy && userId && request.requestedBy === userId) { - throw new ConflictException( - 'You filed this request — a different staff member must approve it', - ); - } - const pendingCheck = - request.action === WagonDetachRequestAction.Maintenance - ? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason) - : await this.removeWagonCore(manager, id, request.wagonId, userId); - await repo.update(request.id, { - status: WagonDetachRequestStatus.Approved, - decidedBy: userId ?? null, - decidedAt: new Date(), - decisionNote, - }); - return pendingCheck; - }); - await this.reconcileWindowAfterConsistChange(pending); - return this.getComposition(id); - } - /** * Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot), * not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/ @@ -1113,6 +1038,9 @@ export class TrainBuilderService { wagon: Wagon, opts: { checkOnly?: boolean } = {}, ): Promise { + // Only DRAFT/SCHEDULED runs still follow the live consist, so only they can + // pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is + // unaffected by what happens to the physical train behind it. const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = await manager.query( `SELECT tsw.id, tsw.train_set_id, ts.status, @@ -1123,15 +1051,18 @@ export class TrainBuilderService { FROM freight.train_set_wagons tsw JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id WHERE tsw.physical_wagon_id = $1 - AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL AND tsw.deleted_at IS NULL`, [wagon.id], ); if (!rows.length) return; - if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) { + // Cargo already allocated to a live run keeps its wagon: the booking must be + // unassigned from the slot first, so a customer's shipment can never lose its + // wagon as a side effect of editing the train. + if (rows.some((r) => Number(r.allocs) > 0)) { throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + `Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`, ); } if (opts.checkOnly) return; @@ -1162,25 +1093,9 @@ export class TrainBuilderService { if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { throw new BadRequestException('Reorder must include every wagon of the train exactly once'); } - // Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder - // is allowed — the pinned schedules' consists are resequenced below so - // they can never desync from the built train's real order. - const dispatched: { exists: boolean }[] = await manager.query( - `SELECT TRUE AS exists - FROM freight.train_set_wagons tsw - JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id - WHERE tsw.physical_wagon_id = ANY($1::uuid[]) - AND ts.status = 'DISPATCHED' - AND ts.deleted_at IS NULL - AND tsw.deleted_at IS NULL - LIMIT 1`, - [[...current]], - ); - if (dispatched.length > 0) { - throw new ConflictException( - 'This train is dispatched — wagons cannot be reordered while it is rolling.', - ); - } + // Reorder is allowed at any time, dispatched runs included: a DISPATCHED + // schedule renders the order frozen in its snapshot, and only the + // DRAFT/SCHEDULED consists resequenced below follow the built train. for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } @@ -1402,6 +1317,7 @@ export class TrainBuilderService { changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>, userId: string | null, yardId: string | null, + reason?: string | null, ): Promise { if (!changes.length) return null; const trainSet = await manager @@ -1443,6 +1359,7 @@ export class TrainBuilderService { wagonNumber: c.wagonNumber, adjustedByUserId: userId, yardId, + reason: reason?.trim() || null, occurredAt: now, }), ), @@ -1481,17 +1398,21 @@ export class TrainBuilderService { } /** Load + freeze the train row for edit; block edits while it is out on a run. */ + /** + * The built train is editable at ANY time, including while it is out on a + * dispatched run. A dispatched/arrived schedule froze its own wagon plan into + * `wagonAllocationSnapshot` at the transition and renders from that, so it can + * never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow + * the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety + * still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon + * whose cargo is allocated to a live run. + */ private async getEditableTrain(manager: EntityManager, id: string): Promise { const train = await manager.getRepository(Train).findOne({ where: { id }, lock: { mode: 'pessimistic_write' }, }); if (!train) throw new NotFoundException(`Train ${id} not found`); - if (train.status === Freight.TrainStatus.InService) { - throw new ConflictException( - `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, - ); - } return train; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index e29eba2c2..ba34c61bf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -20,6 +20,7 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; +import { settlementReferences } from "../billing/invoice-settlement.util"; import { InvoiceDocumentModel, sameCompanyName, @@ -785,6 +786,16 @@ export class WarehouseInvoiceService { ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` : null, }, + // The provider's own transaction number (CBE `FT…`, telebirr receipt no., a + // teller's bank-slip ref) — the row above says only HOW and WHEN it was paid, + // which nobody can reconcile a bank statement against. The warehouse view + // projects the invoice ledger but not the linked gateway `payments` row, so the + // ledger is the only source here; it carries the provider ref on every path + // that has one. + { + label: "Transaction ref", + value: settlementReferences({ payments: invoice.payments }), + }, ], categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 2e31d2a8c..eaa399f03 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -43,6 +43,7 @@ import { Flame, Link2, MapPin, + MoveRight, Package, Receipt, Repeat, @@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft { }; } +/** + * Heaviest load one wagon of this contract's bulk cargo may take, as computed + * by the API from the cargo type's allowed wagon types. Null/undefined when no + * wagon type is configured — the wagon-count check then falls away. + */ +function bulkMaxTonsPerWagon( + contract: Freight.IContract, +): number | null | undefined { + return contract.cargoScope?.find( + (scope) => scope.cargoType?.maxTonsPerWagon != null, + )?.cargoType?.maxTonsPerWagon; +} + function bulkUnitOfMeasure( contract: Freight.IContract, ): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" { @@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() { } if (bulkUom === "NUMBER_OF_WAGONS") { const wagons = Number(bulk.requestedWagons || 0); + const maxPerWagon = Number( + (contract && bulkMaxTonsPerWagon(contract)) || 0, + ); if (!Number.isInteger(wagons) || wagons < 1) { errs.wagons = "Enter the number of wagons needed (at least 1)."; + } else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) { + // Too few wagons for the tonnage can never ride: 200T across 3 wagons + // is 66.67T each on a 50T wagon. Mirrors the server's + // assertWagonShareFits so the button blocks before the API 400s. + errs.wagons = + `${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` + + `${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` + + `this cargo carries at most ${maxPerWagon}T — request at least ` + + `${Math.ceil(qty / maxPerWagon)} wagons.`; } } const h = Number(bulk.hazardousQuantity || 0); @@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() { errs.reefer = `Can't exceed the cargo quantity (${qty}).`; } return errs; - }, [isContainer, bulk, bulkUom]); + }, [isContainer, bulk, bulkUom, contract]); const dateError = !isIntercity && !scheduledDate ? "Select a shipment date." : undefined; @@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() { const header = ( - - {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} + <Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}> + {completeBookingId ? "Complete shipment booking" : "New Shipment Booking"} {completeBookingId - ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` + ? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.` : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} @@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() { styles={fieldStyles} /> ) : ( - - - {selectedRoute?.originYard?.label ?? - selectedRoute?.originYard?.code ?? - "—"}{" "} - →{" "} - {selectedRoute?.destinationYard?.label ?? - selectedRoute?.destinationYard?.code ?? - "—"} - - + + + + {selectedRoute?.originYard?.label ?? + selectedRoute?.originYard?.code ?? + "—"} + + + Origin yard + + + + + + {selectedRoute?.destinationYard?.label ?? + selectedRoute?.destinationYard?.code ?? + "—"} + + + Destination yard + + + + {contract.tradeDirection} - - + + )} @@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() { even numbers. Add one more 20ft container or remove one — book{" "} {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}. - ) : showErrors && !formValid ? ( - } - mb="sm" - > - Fix the highlighted fields before reviewing the price. - ) : partnerError ? ( // The review button is disabled while the parent booking is // incomplete, so the click that would reveal the errors never @@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() { {partnerError} ) : null} - + + + {showErrors && !formValid && !oddBlocksSubmit && ( + <> + + + Fix the highlighted fields to review the price. + + + )} + + + + diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx index ec8b468a6..d3f58d46e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx @@ -1,6 +1,15 @@ import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react"; +import { + ArrowLeftRight, + History, + MapPin, + MessageSquare, + Minus, + Plus, + TrainFront, + User, +} from "lucide-react"; import { useState } from "react"; import { api } from "@/services/api"; @@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) { Who attached, detached or switched which wagon on this train — from - the builder and from its trips — newest first. + the builder and from its trips — newest first, with the reason + given for detaching off a scheduled run. @@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) { ) : null} + {entry.reason ? ( + + + + {entry.reason} + + + ) : null} ); })} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 788265ae2..f63abe12d 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -4,6 +4,7 @@ import { Badge, Box, Button, + Checkbox, Group, Modal, Paper, @@ -12,6 +13,7 @@ import { Select, Stack, Text, + Textarea, ThemeIcon, Tooltip, } from "@mantine/core"; @@ -44,6 +46,7 @@ import { api } from "@/services/api"; import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { + BookingWagonRow, EligibleContainerBooking, FreightType, TrainScheduleDetail, @@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({ ); const [moveBookingId, setMoveBookingId] = useState(null); + // Per-wagon loading/unloading modal for one booking. + const [wagonModal, setWagonModal] = useState<{ + bookingId: string; + ref: string; + phase: "load" | "unload"; + } | null>(null); const [moveTarget, setMoveTarget] = useState(null); // Pool → pick a same-day schedule with free wagons and place the booking there. @@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({ ) : null} + {showLoad && boardHere ? ( + + + + ) : null} {showTruckToTrain ? ( ) : null} + {showUnload && alightHere ? ( + + + + ) : null} {canManage && !riding && !done ? ( journey?.isGovernment ? null : ( @@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({ + {/* Per-wagon load/unload for one booking */} + {wagonModal ? ( + setWagonModal(null)} + onChanged={onChanged} + /> + ) : null} + {/* Pool → same-day train assignment modal */} ); } + +/** + * Per-wagon loading/unloading of one booking. Load phase also offers the + * at-loading cancel of everything not yet loaded: the booking shrinks to its + * loaded wagons (CUSTOMER fault invoices the cancellation fee to pay after; + * EDR fault charges nothing) — required before the train may dispatch. + */ +function PerWagonModal({ + scheduleId, + bookingId, + reference, + phase, + onClose, + onChanged, +}: { + scheduleId: string; + bookingId: string; + reference: string; + phase: "load" | "unload"; + onClose: () => void; + onChanged: () => void; +}) { + const { toast } = useToast(); + const [cancelOpen, setCancelOpen] = useState(false); + const [reason, setReason] = useState(""); + const [edrFault, setEdrFault] = useState(false); + + const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({ + input: { bookingId }, + })); + const wagons: BookingWagonRow[] = wagonsQuery.data ?? []; + const isDone = (w: BookingWagonRow) => + phase === "load" + ? w.status === "LOADED" || w.status === "DEPARTED" + : w.status === "DEPARTED"; + const doneCount = wagons.filter(isDone).length; + const pending = wagons.filter((w) => !isDone(w)); + + const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions()); + const unloadWagon = useMutation( + api.trainScheduling.unloadScheduleBookingWagon.mutationOptions(), + ); + const cancelRemaining = useMutation( + api.trainScheduling.cancelRemainingWagons.mutationOptions(), + ); + const act = phase === "load" ? loadWagon : unloadWagon; + + const errText = (err: unknown) => + isAxiosError(err) + ? ((err.response?.data as { message?: string })?.message ?? err.message) + : String(err); + + const onWagon = (allocationId: string) => { + act + .mutateAsync({ scheduleId, bookingId, allocationId }) + .then((r) => { + void wagonsQuery.refetch(); + if (r.completed) { + toast({ + title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded", + description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`, + }); + onChanged(); + onClose(); + } else { + onChanged(); + } + }) + .catch((err) => + toast({ + title: phase === "load" ? "Wagon load failed" : "Wagon unload failed", + description: errText(err), + variant: "destructive", + }), + ); + }; + + const onCancelRemaining = () => { + cancelRemaining + .mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault }) + .then(() => { + toast({ + title: "Remaining wagons cancelled", + description: edrFault + ? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.` + : `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`, + }); + onChanged(); + onClose(); + }) + .catch((err) => + toast({ + title: "Cancellation failed", + description: errText(err), + variant: "destructive", + }), + ); + }; + + return ( + + + + {phase === "load" ? "Load" : "Unload"} {reference} wagon by wagon + + + } + centered + radius="lg" + size="lg" + > + + + + {doneCount}/{wagons.length} {phase === "load" ? "loaded" : "unloaded"} + + {phase === "load" && doneCount > 0 && pending.length > 0 ? ( + + The train cannot dispatch until the rest are loaded or cancelled. + + ) : null} + + {wagonsQuery.isLoading ? ( + + Loading wagons… + + ) : wagons.length === 0 ? ( + + No wagon allocations yet — use the whole-booking button instead. + + ) : ( + wagons.map((w) => ( + + + + + {w.sequenceNo != null ? `#${w.sequenceNo}` : "—"} + + + {w.wagonNumber ?? w.wagonType ?? "Wagon"} + + + {w.wagonTypeCode ?? ""} + {w.allocatedWeightTons + ? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T` + : ""} + {w.containers?.length ? ` · ${w.containers.length} ctr` : ""} + + + {isDone(w) ? ( + } + > + {phase === "load" ? "Loaded" : "Unloaded"} + + ) : ( + + )} + + + )) + )} + + {phase === "load" && doneCount > 0 && pending.length > 0 ? ( + !cancelOpen ? ( + + ) : ( + + + + Cancel {pending.length} unloaded wagon + {pending.length === 1 ? "" : "s"} of {reference} + + + The booking shrinks to its loaded wagons and the freed freight + becomes a rebookable credit. Customer fault: the cancellation + fee is invoiced, payable afterwards. EDR fault: no fee. + +