diff --git a/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts b/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts new file mode 100644 index 000000000..f07252959 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 3170 backfilled the per-side facility flags onto EXISTING yard_facilities + * rows, but the Djibouti port yards (Negad, the Doraleh terminals) are flagged + * `has_facility` without ever getting a facility record — the seeder only + * covers the inland intercity facilities. With the contract route picker now + * gating on the per-side flags, those yards report false on every side and + * vanish: import contracts lose all origin options, exports all destinations. + * + * Give every facility-flagged yard that has no live record one with both + * freight types open on both sides — exactly the offerability these yards had + * before the gate existed. Ops can narrow a port from the backoffice yards + * page, which now edits these flags. + */ +export class PortYardFacilityRecords3180000000000 implements MigrationInterface { + name = 'PortYardFacilityRecords3180000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.yard_facilities + (yard_id, has_warehouse, handles_container, handles_bulk, + has_container_facility_origin, has_bulk_facility_origin, + has_container_facility_destination, has_bulk_facility_destination) + SELECT y.id, false, true, true, true, true, true, true + FROM freight.yards y + WHERE y.deleted_at IS NULL + AND y.has_facility = true + AND NOT EXISTS ( + SELECT 1 FROM freight.yard_facilities f + WHERE f.yard_id = y.id AND f.deleted_at IS NULL + ) + `); + } + + public async down(): Promise { + // Data seed — the inserted rows are indistinguishable from operator edits + // afterwards, so reversing would risk deleting real configuration. + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 295e9e72b..94dbd2fb8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -26,6 +26,38 @@ export class CreateYardDto { @IsBoolean() hasFacility?: boolean; + @ApiPropertyOptional({ + default: false, + description: 'Facility can load containers onto a train (contract origin side)', + }) + @IsOptional() + @IsBoolean() + hasContainerFacilityOrigin?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can load bulk onto a train (contract origin side)', + }) + @IsOptional() + @IsBoolean() + hasBulkFacilityOrigin?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can receive containers off a train (contract destination side)', + }) + @IsOptional() + @IsBoolean() + hasContainerFacilityDestination?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can receive bulk off a train (contract destination side)', + }) + @IsOptional() + @IsBoolean() + hasBulkFacilityDestination?: boolean; + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 2579c8d22..9602125bb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -27,6 +27,18 @@ export interface YardFacilityInfo { /** Which side of the trip a yard is being considered for. */ export type YardSide = 'ORIGIN' | 'DESTINATION'; +/** + * The four per-side capability flags as stored — NOT gated on the coarse + * `handles*` switches. The yards config page edits the stored values; gating + * is applied only when the flows resolve capability (see `toInfo`). + */ +export interface YardSideFlags { + hasContainerFacilityOrigin: boolean; + hasBulkFacilityOrigin: boolean; + hasContainerFacilityDestination: boolean; + hasBulkFacilityDestination: boolean; +} + /** * Which yards can handle cargo, and what kind. * @@ -116,6 +128,61 @@ export class YardFacilitiesService { return rows.map((r: Parameters[0]) => this.toInfo(r)); } + /** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */ + async sideFlagsForYards(yardIds: string[]): Promise> { + if (yardIds.length === 0) return new Map(); + const rows: Array = await this.dataSource.query( + `SELECT yard_id AS "yardId", + has_container_facility_origin AS "hasContainerFacilityOrigin", + has_bulk_facility_origin AS "hasBulkFacilityOrigin", + has_container_facility_destination AS "hasContainerFacilityDestination", + has_bulk_facility_destination AS "hasBulkFacilityDestination" + FROM freight.yard_facilities + WHERE deleted_at IS NULL AND yard_id = ANY($1)`, + [yardIds], + ); + return new Map( + rows.map((r) => [ + r.yardId, + { + hasContainerFacilityOrigin: r.hasContainerFacilityOrigin, + hasBulkFacilityOrigin: r.hasBulkFacilityOrigin, + hasContainerFacilityDestination: r.hasContainerFacilityDestination, + hasBulkFacilityDestination: r.hasBulkFacilityDestination, + }, + ]), + ); + } + + /** + * Write per-side flags from the yards config form, creating the facility + * record if the yard doesn't have one yet (backoffice-created yards don't). + * Flags left undefined keep their stored value; on first insert they default + * false — an unconfigured facility offers nothing. + */ + async upsertSideFlags(yardId: string, flags: Partial): Promise { + await this.dataSource.query( + `INSERT INTO freight.yard_facilities + (yard_id, has_container_facility_origin, has_bulk_facility_origin, + has_container_facility_destination, has_bulk_facility_destination) + VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false)) + ON CONFLICT (yard_id) WHERE deleted_at IS NULL + DO UPDATE SET + has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin), + has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin), + has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination), + has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination), + updated_at = now()`, + [ + yardId, + flags.hasContainerFacilityOrigin ?? null, + flags.hasBulkFacilityOrigin ?? null, + flags.hasContainerFacilityDestination ?? null, + flags.hasBulkFacilityDestination ?? null, + ], + ); + } + /** * Can this facility lift this cargo? Keeps the freight-type rule in one place * so callers can't get it subtly wrong. diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts index 8affab199..0ec9fe282 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -16,6 +16,10 @@ const service = (): YardsService => update: async (_id: string, d: Partial) => d as Yard, } as never, { resolveCreateOrder: async () => 1 } as never, + { + sideFlagsForYards: async () => new Map(), + upsertSideFlags: async () => undefined, + } as never, ); describe('duplicate yard labels are rejected', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 7dd29ded1..995a901ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto'; import { Yard } from '../entities/yard.entity'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; import { DisplayOrderService } from './display-order.service'; +import { YardFacilitiesService, YardSideFlags } from './yard-facilities.service'; + +/** Yard rows the config page lists/edits carry the stored per-side facility flags. */ +export type YardWithSideFlags = Yard & YardSideFlags; + +const SIDE_FLAG_KEYS = [ + 'hasContainerFacilityOrigin', + 'hasBulkFacilityOrigin', + 'hasContainerFacilityDestination', + 'hasBulkFacilityDestination', +] as const; + +const NO_FLAGS: YardSideFlags = { + hasContainerFacilityOrigin: false, + hasBulkFacilityOrigin: false, + hasContainerFacilityDestination: false, + hasBulkFacilityDestination: false, +}; @Injectable() export class YardsService { @@ -15,18 +33,34 @@ export class YardsService { @Inject(YARDS_REPOSITORY) private readonly repository: IYardsRepository, private readonly displayOrder: DisplayOrderService, + private readonly facilities: YardFacilitiesService, ) {} /** List yards — standard paginated envelope with server-side search. */ - async findAll(query: ListYardsQueryDto): Promise> { - return this.repository.findPaged(query); + async findAll(query: ListYardsQueryDto): Promise> { + const page = await this.repository.findPaged(query); + const flags = await this.facilities.sideFlagsForYards(page.items.map((y) => y.id)); + return { + ...page, + items: page.items.map((y) => ({ ...y, ...NO_FLAGS, ...flags.get(y.id) })), + }; } /** Get a yard by ID. */ - async findById(id: string): Promise { + async findById(id: string): Promise { const entity = await this.repository.findById(id); if (!entity) throw new NotFoundException(`Yard ${id} not found`); - return entity; + const flags = await this.facilities.sideFlagsForYards([id]); + return { ...entity, ...NO_FLAGS, ...flags.get(id) }; + } + + /** The per-side facility flags present in the dto, or null when none were sent. */ + private pickSideFlags(dto: Partial): Partial | null { + const flags: Partial = {}; + for (const key of SIDE_FLAG_KEYS) { + if (dto[key] !== undefined) flags[key] = dto[key]; + } + return Object.keys(flags).length > 0 ? flags : null; } /** Create a yard. */ @@ -43,7 +77,7 @@ export class YardsService { insertAfterId: dto.insertAfterId, }); - return this.repository.create({ + const yard = await this.repository.create({ code, label: dto.label, country: dto.country, @@ -51,15 +85,28 @@ export class YardsService { hasFacility: dto.hasFacility ?? false, displayOrder, }); + + const flags = this.pickSideFlags(dto); + if (flags) await this.facilities.upsertSideFlags(yard.id, flags); + return this.findById(yard.id); } /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); - const updated = await this.repository.update(id, dto); - if (!updated) throw new NotFoundException(`Yard ${id} not found`); - return updated; + + // Per-side flags live on yard_facilities, not the yards row — split them out. + const flags = this.pickSideFlags(dto); + const yardDto = { ...dto }; + for (const key of SIDE_FLAG_KEYS) delete yardDto[key]; + + if (Object.keys(yardDto).length > 0) { + const updated = await this.repository.update(id, yardDto); + if (!updated) throw new NotFoundException(`Yard ${id} not found`); + } + if (flags) await this.facilities.upsertSideFlags(id, flags); + return this.findById(id); } /** No two active yards may share a label (case/whitespace-insensitive). */ 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 87e756ba6..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. @@ -327,19 +334,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); } @@ -435,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')}