From 035369af4333d6a38c654f59d673cbe2e1a7b604 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 1 Aug 2026 09:10:04 +0000 Subject: [PATCH 1/5] fix intercity unloading at facility --- .../booking-journey.service.ts | 37 +++++++++++++++++-- .../backfill-missing-unload-inventory.ts | 8 ++++ 2 files changed, 41 insertions(+), 4 deletions(-) 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 87e756ba6..e5933d35f 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 @@ -327,19 +327,48 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); + if (rows.length === 0) return []; + + // The facility took the cargo off the train at the final yard — raise its + // GRN, same as the per-booking unloadBooking() path does. Only when that + // yard also has a warehouse (or has no facility at all, e.g. Kality) does + // WarehouseInventoryService additionally get to allocate a warehouse/yard/ + // zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is + // fully represented by the facility event alone — there is nothing there + // for warehouse_inventory's NOT NULL warehouse/yard/zone to point at. + const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId); + const bookings = await manager + .getRepository(Booking) + .find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] }); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + for (const row of rows) { // Intercity rows just completed — let a ONE_TIME contract close on delivery. if (row.trade_direction === 'DOMESTIC') { this.events.emit('booking.completed', { bookingId: row.id }); } + + const booking = bookingById.get(row.id); + if (booking) { + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: schedule.destinationStationId, + trainScheduleId: schedule.id, + eventType: 'UNLOAD', + occurredAt: now, + }); + } + // Same event the per-booking unloadBooking() path emits — WarehouseInventoryService // listens for this to auto-create the warehouse_inventory row (import/intercity only, // it filters EXPORT itself). The bulk SQL update above skipped this entirely, so // bookings caught by this fallback never left "awaiting unload". - this.events.emit('booking.unloadedAtYard', { - bookingId: row.id, - tradeDirection: row.trade_direction, - }); + if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) { + this.events.emit('booking.unloadedAtYard', { + bookingId: row.id, + tradeDirection: row.trade_direction, + }); + } } return rows.map((r) => r.id); } diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts index 9d51107fe..608fba613 100644 --- a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -27,12 +27,20 @@ async function main() { const dataSource = app.get(DataSource); const inventory = app.get(WarehouseInventoryService); + // Skip bookings destined for a pure facility yard (has a facility but no + // warehouse, e.g. Dire Dawa) — those are fully represented by their + // facility_handling_events UNLOAD record, not a warehouse_inventory row. + // Same gate as BookingJourneyService.autoArriveAtFinalYard. const bookings: { id: string; tradeDirection: string }[] = await dataSource.query( `SELECT b.id, b.trade_direction AS "tradeDirection" FROM freight.bookings b + JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.yard_facilities yf + ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true WHERE b.deleted_at IS NULL AND b.trade_direction IN ('IMPORT', 'DOMESTIC') AND b.status IN ('ARRIVED', 'COMPLETED') + AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false) AND NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory wi WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL From 6a62ece8142cca4e0dbf4e4ad64d40d1f2634309 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sun, 2 Aug 2026 23:07:32 +0000 Subject: [PATCH 2/5] feat(train-scheduling): auto-place intercity cargo on wagons freed mid-corridor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercity bookings ride the wagons freed by earlier unloads (e.g. import containers uncoupled at Dire Dawa). loadBooking now auto-allocates a DOMESTIC booking onto on-train slots whose cargo has all departed — greedy in consist order by capacity, container numbers copied for the marshalling tally. Falls back to unallocated load when nothing is free. --- .../booking-journey.service.spec.ts | 145 ++++++++++++++++++ .../booking-journey.service.ts | 101 ++++++++++++ .../train-scheduling.controller.ts | 14 ++ .../train-scheduling.service.spec.ts | 126 +++++++++++++++ .../train-scheduling.service.ts | 113 +++++++++++++- .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleTrackPage.tsx | 65 ++++++-- .../TrainScheduleV2DetailPage.tsx | 37 ++++- .../src/services/trainScheduling.service.ts | 10 ++ 9 files changed, 593 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts new file mode 100644 index 000000000..0a8cc99fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -0,0 +1,145 @@ +import { BookingJourneyService } from './booking-journey.service'; + +/** + * autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier + * unloads. Exercised directly with a stubbed EntityManager — the surrounding + * loadBooking flow is integration-tested through the running app. + */ +describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { + const service = new BookingJourneyService( + {} as never, // dataSource + {} as never, // yardFacilities + {} as never, // facilityHandling + { emit: jest.fn() } as never, // events + ); + + const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; + const booking = { + id: 'booking-1', + reference: 'BK-1', + cargoTotalWeightVgm: 50, + freightType: 'CONTAINER', + }; + + const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => { + const savedAllocs: Array> = []; + const savedItems: Array> = []; + const allocQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(existingAllocs), + }; + const slotQb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(slots), + }; + let allocId = 0; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + const name = entity?.name; + if (name === 'WagonBookingAllocation') { + return { + createQueryBuilder: jest.fn(() => allocQb), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + const row = { ...v, id: `alloc-${++allocId}` }; + savedAllocs.push(row); + return row; + }), + update: jest.fn(), + }; + } + if (name === 'TrainSetWagon') { + return { createQueryBuilder: jest.fn(() => slotQb) }; + } + if (name === 'BookingContainer') { + return { + find: jest.fn().mockResolvedValue([ + { + id: 'line-1', + containerNumber: 'LINE-001', + containerTypeId: 'ct-20', + units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }], + }, + ]), + }; + } + if (name === 'WagonAllocationContainerItem') { + return { + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + savedItems.push(v); + return v; + }), + }; + } + throw new Error(`Unexpected repository ${name}`); + }), + }; + return { manager, savedAllocs, savedItems }; + }; + + const call = (manager: unknown) => + (service as never as { + autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise; + }).autoPlaceOnFreedWagons(manager, schedule, booking); + + it('places the booking on freed slots in consist order, with container items', async () => { + const slots = [ + // Active cargo still riding — NOT freed. + { id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] }, + // Freed by an earlier unload. + { id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] }, + { id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] }, + ]; + const { manager, savedAllocs, savedItems } = makeManager(slots); + + await call(manager); + + // 50 t fits on the first freed slot alone. + expect(savedAllocs).toHaveLength(1); + expect(savedAllocs[0]).toMatchObject({ + trainSetWagonId: 'slot-2', + bookingId: 'booking-1', + allocatedWeightTons: 50, + status: 'LOADED', + }); + // One item per physical unit, on the first allocation. + expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']); + expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true); + }); + + it('spills over onto the next freed slot when one is not enough', async () => { + const slots = [ + { id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] }, + { id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] }, + ]; + const { manager, savedAllocs } = makeManager(slots); + + await call(manager); + + expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([ + ['slot-2', 30], + ['slot-3', 20], + ]); + }); + + it('does nothing when the booking already has allocations', async () => { + const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]); + await call(manager); + expect(savedAllocs).toHaveLength(0); + }); + + it('loads without allocation when no wagon is free', async () => { + const slots = [ + { id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] }, + ]; + const { manager, savedAllocs } = makeManager(slots); + await expect(call(manager)).resolves.toBeUndefined(); + expect(savedAllocs).toHaveLength(0); + }); +}); 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 e5933d35f..8668b4f75 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 @@ -13,6 +13,8 @@ import { Freight } from '@edr/types'; import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; import { FacilityHandlingService } from './facility-handling.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; @@ -82,6 +84,11 @@ export class BookingJourneyService { loadedAt: now, loadedByUserId: userId ?? null, } as never); + // Intercity cargo rides the wagons freed by earlier unloads along the + // corridor — place it before the status flip so it boards with a wagon. + if (booking.tradeDirection === 'DOMESTIC') { + await this.autoPlaceOnFreedWagons(manager, schedule, booking); + } await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. @@ -464,6 +471,100 @@ export class BookingJourneyService { } } + /** + * INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the + * slots freed by cargo already unloaded along the corridor (e.g. import + * containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool, + * so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over + * on-train slots (not DEPARTED) with no active cargo (every allocation + * DEPARTED, or none), in consist order, by capacity. Container numbers are + * copied onto the first allocation so the marshalling document and its + * 40ft/20ft tally stay truthful. When nothing is free the load proceeds + * unallocated — the marshalling document then lists the booking as on board + * without a recorded wagon. + * ponytail: remainder over free capacity is dumped on the last used slot + * (paper overload beats missing cargo); upgrade path is a capacity guard in + * the intercity accept step. + */ + private async autoPlaceOnFreedWagons( + manager: EntityManager, + schedule: TrainSchedule, + booking: Booking, + ): Promise { + const existing = await this.allocationsForBooking(manager, schedule.id, booking.id); + if (existing.length) return; + + const slots = await manager + .getRepository(TrainSetWagon) + .createQueryBuilder('slot') + .leftJoinAndSelect('slot.allocations', 'alloc') + .innerJoin( + TrainSchedule, + 'schedule', + 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', + { scheduleId: schedule.id }, + ) + .where(`slot.status != 'DEPARTED'`) + .orderBy('slot.sequence_no', 'ASC') + .getMany(); + const freed = slots.filter((slot) => + (slot.allocations ?? []).every((a) => a.status === 'DEPARTED'), + ); + if (!freed.length) { + this.logger.warn( + `No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`, + ); + return; + } + + let remaining = Number(booking.cargoTotalWeightVgm) || 0; + const allocRepo = manager.getRepository(WagonBookingAllocation); + const created: WagonBookingAllocation[] = []; + for (const slot of freed) { + const capacity = Number(slot.capacityTons) || remaining || 1; + const take = Math.min(remaining || capacity, capacity); + created.push( + await allocRepo.save( + allocRepo.create({ + trainSetWagonId: slot.id, + bookingId: booking.id, + allocatedWeightTons: take, + loadType: booking.freightType ?? null, + status: 'LOADED', + }), + ), + ); + remaining = Math.max(0, remaining - take); + if (remaining <= 0) break; + } + if (remaining > 0 && created.length) { + await allocRepo.update(created[created.length - 1].id, { + allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`, + } as never); + } + + // Container numbers onto the first allocation, from the booking's container + // lines (per physical unit when recorded, else per line). + const lines = await manager + .getRepository(BookingContainer) + .find({ where: { bookingId: booking.id }, relations: { units: true } }); + const itemRepo = manager.getRepository(WagonAllocationContainerItem); + const first = created[0]; + for (const line of lines) { + const units = line.units?.length ? line.units : [null]; + for (const unit of units) { + await itemRepo.save( + itemRepo.create({ + wagonBookingAllocationId: first.id, + bookingContainerId: line.id, + containerNumber: unit?.containerNumber ?? line.containerNumber ?? null, + containerTypeId: line.containerTypeId ?? null, + }), + ); + } + } + } + private async setAllocationStatuses( manager: EntityManager, scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 485a3ceea..ed1b52318 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -711,6 +711,20 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/intercity/marshalling/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download current on-board intercity marshalling (Marshalling 2) PDF" }) + async intercityMarshallingDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.intercityMarshallingDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 406f51988..d2e1268ea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1121,6 +1121,132 @@ describe('TrainSchedulingService', () => { expect(html).not.toContain('empty)'); expect(html).not.toContain('EMPTY'); }); + + // ---- intercity marshalling (Marshalling 2): the current on-board view ---- + + const onBoardView = (schedule: unknown) => + (service as never as { + intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] }; + }).intercityOnBoardView(schedule); + + const buildWithOpts = (schedule: unknown, opts: unknown) => + (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, opts); + + const allocWith = (over: Record) => ({ ...loadedAllocation, ...over }); + + it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => { + const schedule = { + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' }, + { + ...makeWagon(3, 'W-003', [ + allocWith({ status: 'LOADED', bookingId: 'booking-3' }), + allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }), + ]), + status: 'RESERVED', + }, + ], + }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001', 'W-003']); + const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find( + (w) => w.physicalWagon.wagonNumber === 'W-003', + ); + expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']); + }); + + it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' }, + ], + }, + scheduleBookings: [], + }; + + const { wagons, unassignedBookings } = onBoardView(schedule); + const html = buildWithOpts(schedule, { wagons, unassignedBookings }); + expect(html).toContain('W-002'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1); + expect(html).toContain('2 (1 empty)'); + }); + + it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => { + const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; + const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; + const schedule = { + trainSet: { wagons: [legWagonEmpty, legWagonLoaded] }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-LEG2']); + }); + + it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => { + const rider = { + id: 'booking-9', + reference: 'BK-2026-000009', + status: 'IN_TRANSIT', + company: { name: 'Rider Co' }, + cargoType: { cargoTypeName: 'Cement', code: 'CEM' }, + originYard: { label: 'Adama' }, + destinationYard: { label: 'Dire Dawa' }, + bookingContainers: [{ containerNumber: 'RIDE-001' }], + }; + const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] }, + scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }], + }; + + const { wagons, unassignedBookings } = onBoardView(schedule); + expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']); + + const html = buildWithOpts(schedule, { + title: 'Intercity Marshalling Document / Load List (Marshalling 2)', + positionLabel: 'After Dire Dawa', + wagons, + unassignedBookings, + }); + expect(html).toContain('ON BOARD — WAGON NOT RECORDED'); + expect(html).toContain('BK-2026-000009'); + expect(html).toContain('RIDE-001'); + expect(html).not.toContain('BK-2026-000008'); + expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)'); + expect(html).toContain('After Dire Dawa'); + }); + + it('rejects the intercity marshalling document for a train that has not been dispatched', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'schedule-1', + status: 'SCHEDULED', + }); + await expect( + service.intercityMarshallingDocument('schedule-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('moveWagonLoad — staff rearrange', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 495feedf0..ce95b208e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2928,6 +2928,78 @@ export class TrainSchedulingService { }; } + /** + * The train's composition as it stands right now — the source for the + * intercity marshalling ("Marshalling 2") document printed after mid-corridor + * station work. A wagon slot is on the train iff it has not DEPARTED and + * either rides the whole corridor (no boardYardId) or has confirmed LOADED + * cargo. Kept wagons carry only their LOADED allocations (DEPARTED = + * unloaded, PLANNED/RESERVED = not on board yet). + * ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade + * path is comparing the board yard against the latest checkpoint sequence. + */ + private intercityOnBoardView(schedule: TrainSchedule): { + wagons: TrainSetWagon[]; + unassignedBookings: Booking[]; + } { + const wagons = (schedule.trainSet?.wagons ?? []) + .filter((wagon) => { + if (wagon.status === 'DEPARTED') return false; + const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); + return wagon.boardYardId == null || hasLoaded; + }) + .map((wagon) => ({ + ...wagon, + allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'), + })) as TrainSetWagon[]; + + const onBoardBookingIds = new Set( + wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)), + ); + // IN_TRANSIT bookings with no kept allocation: intercity riders accepted + // after dispatch (never wagon-pinned) and loads whose allocation was never + // confirmed LOADED. They are physically on the train, so they get a row. + const unassignedBookings = (schedule.scheduleBookings ?? []) + .map((link) => link.booking) + .filter((booking): booking is Booking => Boolean(booking)) + .filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id)); + + return { wagons, unassignedBookings }; + } + + async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') { + throw new BadRequestException( + 'Intercity marshalling document applies only to dispatched or arrived trains', + ); + } + + const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const last = checkpoints[checkpoints.length - 1]; + const positionLabel = last + ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` + : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; + + const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const html = this.buildExportLoadListHtml(schedule, { + title: 'Intercity Marshalling Document / Load List (Marshalling 2)', + positionLabel, + wagons, + unassignedBookings, + }); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + /** * A container item's size in feet, for the marshalling document's 40ft/20ft * tally. Two independent sources, since only one is populated depending on @@ -2954,7 +3026,15 @@ export class TrainSchedulingService { return null; } - private buildExportLoadListHtml(schedule: TrainSchedule): string { + private buildExportLoadListHtml( + schedule: TrainSchedule, + opts?: { + title?: string; + positionLabel?: string; + wagons?: TrainSetWagon[]; + unassignedBookings?: Booking[]; + }, + ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') @@ -2967,7 +3047,7 @@ export class TrainSchedulingService { const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in // consist order — the relation comes back unordered. - const wagons = [...(schedule.trainSet?.wagons ?? [])].sort( + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), ); const rows = wagons @@ -3011,6 +3091,29 @@ export class TrainSchedulingService { }); }) .join(''); + // Intercity riders accepted after dispatch have no wagon slot recorded — + // they are still physically on the train, so they get rows of their own. + const unassigned = opts?.unassignedBookings ?? []; + const unassignedRows = unassigned.length + ? `ON BOARD — WAGON NOT RECORDED` + + unassigned + .map((booking) => { + const containerNumbers = (booking.bookingContainers ?? []) + .map((container) => container.containerNumber) + .filter(Boolean) + .join(', '); + const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; + return ` + ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} + ${esc(booking.company?.name)} + ${esc(containerNumbers)} + - + - + `; + }) + .join('') + : ''; const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; const totalWeight = wagons.reduce( (sum, wagon) => @@ -3034,7 +3137,7 @@ export class TrainSchedulingService { - Export Marshalling Document + ${esc(opts?.title ?? 'Export Marshalling Document')}