diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 5cdb0d6a4..da7811ecb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -112,6 +112,9 @@ interface CarriageAcceptanceWagonRow { departureAt: Date | null; marshalledAt: string | null; arrivalAt: string | null; + /** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */ + departureStation: string | null; + arrivalStation: string | null; containerNumbers: string | null; sealNumbers: string | null; /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ @@ -291,6 +294,8 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + COALESCE(by_.label, so.label) AS "departureStation", + COALESCE(ay.label, sd.label) AS "arrivalStation", a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" @@ -303,11 +308,14 @@ export class BookingsService { ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, - s.train_number, s.scheduled_departure_date, so.label, sd.label + s.train_number, s.scheduled_departure_date, so.label, sd.label, + by_.label, ay.label ORDER BY tsw.sequence_no`, [bookingId], ); @@ -388,6 +396,8 @@ export class BookingsService { departureAt: null, marshalledAt: null, arrivalAt: null, + departureStation: null, + arrivalStation: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, // A received line has no allocation; it is cargo EDR already holds. @@ -539,9 +549,9 @@ export class BookingsService { ${num(w.tareWeightTons, 2)} ${num(w.equatedLength)} ${num(w.loadCapacityTons)} - ${esc(arrivalStation)} + ${esc(w.arrivalStation ?? arrivalStation)} ${esc(cargoName)} - ${esc(departureStation)} + ${esc(w.departureStation ?? departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} ${ @@ -558,16 +568,12 @@ export class BookingsService { const totalsRow = ` TOT ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} - ${ - pendingWagons - ? 'pending marshalling' - : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}` - } + ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} - Gross ${num(totals.tare + totals.load)} T + @@ -575,6 +581,23 @@ export class BookingsService { ${money(totalAmount)} `; + // The signed footer of the paper sheet. Rendered as .tile so the + // Chromium-less fallback (buildTabularFallbackPdf parses .tile, not + // arbitrary divs) still prints every figure. + const footer = ` + `; + return ` @@ -591,6 +614,8 @@ export class BookingsService { .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; } .meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; } .summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; } + .footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; } + .footer-summary .tile { background: #f8fafc; } .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } @@ -652,6 +677,7 @@ export class BookingsService { ${totalsRow} +${footer}
${ diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts index 195e0cab0..45907c09a 100644 --- a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => { expect(shares).toEqual([33.33, 33.33, 33.34]); }); }); + +// The HTML builder only reaches `this` for two prototype helpers (escapeHtml, +// splitAmountAcrossWagons), so the prototype itself serves as `this`. +const buildSheet = (wagons: unknown[], booking: Record = {}): string => + ( + BookingsService.prototype as unknown as { + buildCarriageAcceptanceSheetHtml( + b: unknown, + w: unknown[], + o: { pendingWagons: boolean }, + ): string; + } + ).buildCarriageAcceptanceSheetHtml.call( + BookingsService.prototype, + { + reference: 'BK-1', + tradeDirection: 'EXPORT', + totalAmount: 100, + paymentCurrency: 'ETB', + originYard: { label: 'Booking Origin' }, + destinationYard: { label: 'Booking Destination' }, + ...booking, + }, + wagons, + { pendingWagons: false }, + ); + +const wagon = (over: Record = {}) => ({ + sequenceNo: 1, + wagonType: 'FLAT', + wagonNumber: 'W-001', + tareWeightTons: '20', + equatedLength: '14', + loadCapacityTons: '60', + allocatedWeightTons: '40', + trainNumber: '8302', + departureAt: null, + marshalledAt: 'DCT/SGTD', + arrivalAt: 'GMP', + departureStation: null, + arrivalStation: null, + containerNumbers: 'CN-1', + sealNumbers: 'SL-1', + status: 'LOADED', + ...over, +}); + +describe('carriage acceptance sheet — rows and footer', () => { + it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => { + const html = buildSheet([ + wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }), + wagon({ sequenceNo: 2, wagonNumber: 'W-002' }), + ]); + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + expect(html).toContain('Booking Origin'); + expect(html).toContain('Booking Destination'); + }); + + it('totals the footer over loaded wagons only', () => { + const html = buildSheet([ + wagon(), + wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }), + wagon({ + sequenceNo: 3, + wagonNumber: 'W-003', + allocatedWeightTons: '0', + containerNumbers: null, + }), + ]); + // 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load. + expect(html).toContain('In Total Wagon No.2'); + expect(html).toContain('Tare Weight (T)40.00'); + expect(html).toContain('Load Capacity (T)120.000'); + expect(html).toContain('Gross Weight (T)80.000'); + expect(html).toContain('Equated Length28.000'); + expect(html).toContain('Full Wagon1'); + expect(html).toContain('Empty Wagon1'); + expect(html).toContain('Total Amount (ETB)100.00'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 64af1a56f..fb64b1e5c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1062,6 +1062,7 @@ describe('TrainSchedulingService', () => { const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ sequenceNo, wagonNumber, + physicalWagonId: `wagon-id-${wagonNumber}`, physicalWagon: { wagonNumber }, wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, lengthMeters: 14, @@ -1185,6 +1186,57 @@ describe('TrainSchedulingService', () => { expect(html).toContain('Total containers1'); }); + it('drops a slot with no physical wagon pinned from the import document too', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '7002', + route: 'DCT/SGTD → GMP', + origin: 'DCT/SGTD', + destination: 'GMP', + totalBookings: 2, + wagons: [ + { + sequenceNo: 1, + // No physical wagon pinned (fleet shortfall, or a REAL cut nulled + // it out) — nothing physical to marshal, even though the slot + // still carries a LOADED allocation. + wagonNumber: null, + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'GHOST-001' }], + }, + ], + }, + { + sequenceNo: 2, + wagonNumber: 'W-IMP', + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }, + ], + }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(html).not.toContain('GHOST-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { const sizedAllocation = { ...loadedAllocation, @@ -1211,6 +1263,29 @@ describe('TrainSchedulingService', () => { expect(html).toContain('Total containers1'); }); + it('drops a whole-route slot with no physical wagon pinned from the export document too', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, {}); + + expect(html).not.toContain('W-GHOST'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + it('prints the consist-changes table for this stop, and omits it when there are none', () => { const schedule = { id: 'schedule-1', @@ -1444,6 +1519,22 @@ describe('TrainSchedulingService', () => { expect(numbers).toEqual(['W-LEG2']); }); + it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => { + // A booking can hold a LOADED allocation before a real wagon backs it + // (fleet shortfall left the slot unpinned), or a REAL cut nulls + // physicalWagonId without ever touching the slot's own status. Either + // way there is no physical wagon standing there to marshal. + const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null }; + const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001']); + }); + it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => { // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded // at Adama, a LATER stop) read identically to intercityOnBoardView by 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 07d386df3..9e0489012 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 @@ -3602,6 +3602,13 @@ export class TrainSchedulingService { const wagons = (schedule.trainSet?.wagons ?? []) .filter((wagon) => { if (wagon.status === 'DEPARTED') return false; + // No physical wagon pinned to the slot — a booking can hold an + // allocation before a real wagon backs it (e.g. a fleet shortfall + // left it unpinned). There is nothing physical here to marshal, and + // a REAL cut also lands here: it nulls physicalWagonId without ever + // touching this slot's own status, so a cut wagon would otherwise + // linger as a phantom row with its cargo still listed. + if (!wagon.physicalWagonId) return false; const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); return wagon.boardYardId == null || hasLoaded; }) @@ -3930,6 +3937,11 @@ export class TrainSchedulingService { // Their own coupling shows up on THAT stop's own marshalling document. const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + // No physical wagon pinned to the slot (fleet shortfall left a booking's + // allocation unpinned, or a REAL cut nulled it out): nothing physical + // to marshal, so no row. Harmless no-op for the numbered docs, whose + // wagons list already went through intercityOnBoardView's own check. + .filter((wagon) => Boolean(wagon.physicalWagonId)) .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. @@ -4323,8 +4335,11 @@ export class TrainSchedulingService { // A leg slot (boardYard set) couples mid-corridor — it is not part of the // consist this Djibouti-side document is checked against yet, so it gets // no row and no count here at all. Its own coupling shows up on THAT - // stop's own marshalling document once it actually happens. - const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard); + // stop's own marshalling document once it actually happens. Same for a + // slot with no physical wagon pinned at all — a booking can hold an + // allocation before a real wagon backs it (fleet shortfall), or a REAL + // cut nulled it out; either way there is nothing physical to marshal. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null); const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const totalWeight = wagons.reduce( (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 43e7d7ae8..653c2ea5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -319,6 +319,14 @@ export interface LoadableTrainRow { destination: string | null; status: string; departureTime: string | Date | null; + /** freight.yards.id the train departs from — the default boarding yard. */ + originStationId: string | null; + /** + * The schedule's per-yard loading/unloading time windows, exactly as the train + * schedule page stores them. The warehouse loading queues render the same + * Start/End controls off this, so both surfaces show one truth. + */ + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -340,7 +348,12 @@ export interface TrainLoadableItemRow { wagonId: string | null; wagonNumber: string | null; sequenceNo: number | null; - /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + /** The booking's boarding yard — the yard whose loading window gates this item. */ + originYardId: string | null; + originYardLabel: string | null; + /** True once "Start loading" was clicked for this item's boarding yard on this train. */ + loadingWindowStarted: boolean; + /** True only when the item is READY_FOR_LOADING, has an allocated wagon and GRN, and its yard's loading window is open. */ loadable: boolean; }