From 1ab2cfdb3b41387d52c60a73828311c81bda8b7e Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 22 Aug 2026 01:23:50 +0000 Subject: [PATCH] fix issue --- ...0000000000-SchedulePlannedWagonCutYards.ts | 29 ++ .../entities/train-schedule.entity.ts | 9 + .../train-scheduling/booking-batch.service.ts | 5 + .../booking-batch.smart-need.spec.ts | 34 ++ .../container-placement.util.spec.ts | 85 +++- .../container-placement.util.ts | 133 +++++-- .../train-scheduling.controller.ts | 4 +- .../corridor-capacity.util.spec.ts | 104 ++++- .../corridor-capacity.util.ts | 71 +++- .../dto/update-schedule-wagon-yards.dto.ts | 23 +- .../services/train-scheduling.service.ts | 368 +++++++++++++++--- .../ScheduleWagonYardPanel.tsx | 110 +++++- .../src/services/trainBuilder.service.ts | 8 +- 13 files changed, 874 insertions(+), 109 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts diff --git a/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts new file mode 100644 index 000000000..d061cd9e0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-SchedulePlannedWagonCutYards.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule wagon CUT plan — the mid-route stop where THIS departure + * detaches each consist wagon and leaves it behind (10 wagons cut at Mojo, + * the rest ride to Djibouti). + * + * Sparse jsonb map `{ wagonId: yardId }` on the schedule: a wagon missing + * from the map rides to the schedule destination — exactly today's behavior, + * so no backfill. The cut is a cap, not a promise: cargo may still alight + * earlier, but never past the cut. Booking capacity debits every edge at or + * after the cut; checkpoint logging settles the wagon there physically. + */ +export class SchedulePlannedWagonCutYards3650000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonCutYards3650000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index b3cb3e821..280b7f83b 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -130,6 +130,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true }) plannedWagonYards?: Record | null; + /** + * Where THIS departure plans to CUT (detach and leave) each consist wagon: + * `{ wagonId: yardId }`. Sparse — a wagon absent from the map rides to the + * schedule destination. A cap, not a promise: cargo may alight earlier, but + * validation forbids cargo allocated past the cut. + */ + @Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true }) + plannedWagonCutYards?: Record | null; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 79a902a65..ffcf936bd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -101,6 +101,7 @@ import { CorridorLeg, OverageTolerance, stopYardsFor, + subtractCutWagons, } from './corridor-capacity.util'; import { WagonStockLedger } from './wagon-stock-ledger.util'; @@ -5358,6 +5359,10 @@ export class BookingBatchService implements OnModuleInit { // wagon serves disjoint legs — capacity freed past an alight yard is real. const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); + // Wagons staff plan to cut mid-route are gone from every edge past the cut. + // ponytail: the wagon-type stock ledger stays cut-blind; bucket + // builtTrainStock by (yard, reach) if mixed-type cut trains appear. + subtractCutWagons(budget, schedule.plannedWagonCutYards); for (const b of await this.committedBookings(schedule, excludeBookingIds)) { budget.subtract( this.needFor(b, wagonDims), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts index bf64f0413..922ec2335 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -86,6 +86,40 @@ describe('BookingBatchService.smartBulkNeed', () => { expect(call(booking(695), stock, contested)).toBeNull(); }); + it('S-2026-00020 shape: 42x40ft eat the NW5, 200T bulk still seats on the 10 coupled PW2', () => { + // The staging complaint: a built train of 42 NW5 + 10 PW2, containers + // hold every NW5, and a bulk booking sits in "Ready for batch" while the + // PW2 ride empty. The chain: committed containers drain NW5 from the + // ledger (their types cannot touch PW2), then the smart gate must seat + // 200T of Perishable on the 10 PW2 at the 20T cap. + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 42], + [pw2.id, 10], + ]), + 2, // DCT -> Dire -> GMP: two edges + ); + // Committed container booking rides Dire->GMP (edge 1) on 42 NW5 — + // container-capable types only, exactly how stockLedgerFor debits it. + stock.consume([nw5.id], 42, { fromEdge: 1, toEdge: 2 }); + expect(stock.availableFor([nw5.id], { fromEdge: 0, toEdge: 2 })).toBe(0); + expect(stock.availableFor([pw2.id], { fromEdge: 0, toEdge: 2 })).toBe(10); + + const smart = ( + service as unknown as { + smartBulkNeed: ( + b: Booking, + d: unknown, + s: WagonStockLedger, + l: { fromEdge: number; toEdge: number }, + r: Map, + ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; + } + ).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested); + expect(smart).not.toBeNull(); + expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]); + }); + it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => { const stock = new WagonStockLedger( new Map([ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts index f51199b8c..a97ceebe5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts @@ -1,6 +1,7 @@ import { autoFillPlacements, findMissingContainerNumberIssues, + occupiedTeuPerEdgeBySlot, type ContainerUnitForPlacement, } from './container-placement.util'; @@ -27,14 +28,15 @@ describe('container-placement.util', () => { ]; it('auto-fills placements across slots', () => { - const placements = autoFillPlacements(units, [1, 2]); + const { placements, overflow } = autoFillPlacements(units, [1, 2]); expect(placements).toHaveLength(2); + expect(overflow).toHaveLength(0); expect(placements[0].sequenceNo).toBe(1); expect(placements[1].sequenceNo).toBe(2); }); it('reports missing container numbers only when placement is empty', () => { - const placements = autoFillPlacements(units, [1, 2]); + const { placements } = autoFillPlacements(units, [1, 2]); const issues = findMissingContainerNumberIssues(units, placements); expect(issues).toHaveLength(0); expect(placements[1].containerNumber).toMatch(/^TBD-/); @@ -53,7 +55,84 @@ describe('container-placement.util', () => { containerNumber: null, }, ]; - const placements = autoFillPlacements(single, [1]); + const { placements } = autoFillPlacements(single, [1]); expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1'); }); + + const ft40 = ( + bookingId: string, + i: number, + leg?: { from: number; to: number }, + ): ContainerUnitForPlacement => ({ + bookingId, + bookingReference: bookingId, + bookingContainerId: `${bookingId}-line`, + unitIndex: i, + label: `${bookingId} · ${i + 1} · 40GP`, + teuSlots: 2, + sizeFt: 40, + containerNumber: `CNT${bookingId}${i}`, + leg, + }); + + it('never clamps overflow onto the last slot — returns it instead', () => { + // 3 × 40ft, 2 slots. The old walk piled unit 3 onto slot #2 and let the + // validator reject it once per container ("Wagon #42…", the reported bug). + const three = [ft40('A', 0), ft40('A', 1), ft40('A', 2)]; + const { placements, overflow } = autoFillPlacements(three, [1, 2]); + expect(placements).toHaveLength(2); + expect(overflow).toHaveLength(1); + expect(placements.every((p) => p.sequenceNo === 1 || p.sequenceNo === 2)).toBe(true); + }); + + it('leg-aware: disjoint-leg 40fts share one wagon (the staging case)', () => { + // 2 slots riding the whole 2-edge route. Leg-blind fill fits only two of + // these four 40fts; per-edge TEU fits all four — two per wagon, one per leg. + const slots = [ + { sequenceNo: 1, from: 0, to: 2 }, + { sequenceNo: 2, from: 0, to: 2 }, + ]; + const four = [ + ft40('LEG1', 0, { from: 0, to: 1 }), + ft40('LEG1', 1, { from: 0, to: 1 }), + ft40('LEG2', 0, { from: 1, to: 2 }), + ft40('LEG2', 1, { from: 1, to: 2 }), + ]; + const { placements, overflow } = autoFillPlacements(four, slots, new Map(), 2); + expect(overflow).toHaveLength(0); + expect(placements).toHaveLength(4); + }); + + it('same-leg 40fts still never share a wagon', () => { + const slots = [{ sequenceNo: 1, from: 0, to: 2 }]; + const two = [ft40('X', 0, { from: 0, to: 1 }), ft40('X', 1, { from: 0, to: 1 })]; + const { placements, overflow } = autoFillPlacements(two, slots, new Map(), 2); + expect(placements).toHaveLength(1); + expect(overflow).toHaveLength(1); + }); + + it('respects per-edge occupied TEU from caller-provided placements', () => { + const slots = [{ sequenceNo: 1, from: 0, to: 2 }]; + const provided = [{ bookingContainerId: 'P-line', unitIndex: 0, sequenceNo: 1 }]; + const providedUnit = ft40('P', 0, { from: 0, to: 1 }); + const occupied = occupiedTeuPerEdgeBySlot(provided, [providedUnit], 2); + // Edge 0 is full on slot 1; an edge-0 unit overflows, an edge-1 unit fits. + const edge0 = autoFillPlacements([ft40('Q', 0, { from: 0, to: 1 })], slots, occupied, 2); + expect(edge0.overflow).toHaveLength(1); + const edge1 = autoFillPlacements([ft40('Q', 0, { from: 1, to: 2 })], slots, occupied, 2); + expect(edge1.overflow).toHaveLength(0); + expect(edge1.placements[0].sequenceNo).toBe(1); + }); + + it('a unit never lands on a slot that does not ride its leg', () => { + const slots = [{ sequenceNo: 1, from: 0, to: 1 }]; // alights at stop 1 + const { placements, overflow } = autoFillPlacements( + [ft40('Y', 0, { from: 1, to: 2 })], + slots, + new Map(), + 2, + ); + expect(placements).toHaveLength(0); + expect(overflow).toHaveLength(1); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts index a0f709cdf..e9eed6a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts @@ -9,6 +9,18 @@ export type ContainerUnitForPlacement = { teuSlots?: number; sizeFt?: number; containerNumber?: string | null; + /** + * Stop-index span this unit's BOOKING rides (leg-aware trains). Omitted → + * the whole route, which is exact for single-leg schedules. + */ + leg?: { from: number; to: number }; +}; + +/** A container-capable wagon slot with the stop-index span it physically rides. */ +export type ContainerSlotForPlacement = { + sequenceNo: number; + from: number; + to: number; }; export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string { @@ -25,51 +37,118 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string return trimmed || placeholderContainerNumber(unit); } +/** + * Auto-place container units onto the plan's container slots. + * + * TEU is tracked PER CORRIDOR EDGE, because that is how the planner and the + * validator count it: a wagon whose 40ft alights at Dire Dawa has both TEU + * free again for a 40ft boarding there. The old whole-route walk believed a + * wagon was full after one 40ft on ANY leg, ran out of slots on a leg-sharing + * train, and — worse — CLAMPED every leftover unit onto the last slot. That + * produced placements the validator then rejected one by one ("Wagon #42 + * cannot fit another 40FT… total weight 560T"), a wall of errors for what is + * really one condition. + * + * Units that genuinely fit nowhere are returned in `overflow` — never + * force-placed. The caller owns turning that into ONE honest message. + * + * `containerSlots` may be plain sequence numbers (whole-route spans — exact + * for single-leg schedules and identical to the old behaviour) or spans. + */ export function autoFillPlacements( units: ContainerUnitForPlacement[], - containerSlots: number[], + containerSlots: ReadonlyArray, /** * TEU already taken per slot sequenceNo by placements the caller supplied. - * Without it a partial auto-fill restarted at wagon #1 and stacked a second - * 40ft onto a wagon another booking's placement had already filled. + * A plain number occupies every edge of the slot; an array is per-edge. */ - occupiedTeuBySlot: ReadonlyMap = new Map(), -): ContainerPlacementInput[] { - if (!units.length || !containerSlots.length) return []; - + occupiedTeuBySlot: ReadonlyMap = new Map(), + edgeCount = 1, +): { placements: ContainerPlacementInput[]; overflow: ContainerUnitForPlacement[] } { + const edges = Math.max(1, edgeCount); + const slots: ContainerSlotForPlacement[] = containerSlots.map((s) => + typeof s === 'number' ? { sequenceNo: s, from: 0, to: edges } : s, + ); const placements: ContainerPlacementInput[] = []; + const overflow: ContainerUnitForPlacement[] = []; + if (!units.length) return { placements, overflow }; + if (!slots.length) return { placements, overflow: [...units] }; + const MAX_TEU_PER_WAGON = 2; - let currentSlotIndex = 0; - let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0; + const used = new Map(); + const usedRow = (sequenceNo: number): number[] => { + let row = used.get(sequenceNo); + if (!row) { + const seed = occupiedTeuBySlot.get(sequenceNo) ?? 0; + row = + typeof seed === 'number' + ? new Array(edges).fill(seed) + : Array.from({ length: edges }, (_, e) => seed[e] ?? 0); + used.set(sequenceNo, row); + } + return row; + }; + + const legOf = (unit: ContainerUnitForPlacement): { from: number; to: number } => { + const leg = unit.leg; + if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) { + return { from: 0, to: edges }; + } + return leg; + }; for (const unit of units) { const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1); - - while ( - teuInCurrentSlot > 0 && - teuInCurrentSlot + teu > MAX_TEU_PER_WAGON && - currentSlotIndex < containerSlots.length - 1 - ) { - currentSlotIndex += 1; - teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0; + const leg = legOf(unit); + const slot = slots.find((s) => { + if (s.from > leg.from || leg.to > s.to) return false; + const row = usedRow(s.sequenceNo); + for (let e = leg.from; e < leg.to; e += 1) { + if ((row[e] ?? 0) + teu > MAX_TEU_PER_WAGON) return false; + } + return true; + }); + if (!slot) { + overflow.push(unit); + continue; } - - const sequenceNo = - containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ?? - containerSlots[containerSlots.length - 1] ?? - containerSlots[0]; - + const row = usedRow(slot.sequenceNo); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu; placements.push({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, - sequenceNo, + sequenceNo: slot.sequenceNo, containerNumber: resolveContainerNumber(unit), }); - - teuInCurrentSlot += teu; } - return placements; + return { placements, overflow }; +} + +/** + * Per-edge TEU consumed by the given placements, using each placed unit's own + * leg — the seed `autoFillPlacements` needs on a leg-aware train. + */ +export function occupiedTeuPerEdgeBySlot( + placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>, + units: ContainerUnitForPlacement[], + edgeCount: number, +): Map { + const edges = Math.max(1, edgeCount); + const unitByKey = new Map(units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u])); + const out = new Map(); + for (const p of placements) { + const unit = unitByKey.get(`${p.bookingContainerId}:${p.unitIndex}`); + const teu = unit ? (unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1)) : 1; + const leg = + unit?.leg && unit.leg.from >= 0 && unit.leg.to <= edges && unit.leg.from < unit.leg.to + ? unit.leg + : { from: 0, to: edges }; + const row = out.get(p.sequenceNo) ?? new Array(edges).fill(0); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu; + out.set(p.sequenceNo, row); + } + return out; } /** TEU per slot sequenceNo consumed by the given placements. */ 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 834b3d174..6afcf9380 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 @@ -206,7 +206,7 @@ export class TrainSchedulingController { @TrainSchedulingView() @ApiOperation({ summary: - "Schedule wagon yard plan: where THIS departure boards each consist wagon vs where it physically stands, per-stop totals, locked wagons", + "Schedule wagon yard plan: where THIS departure boards and cuts each consist wagon vs where it physically stands, per-stop totals, locked wagons", }) getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) { return this.trainSchedulingService.getScheduleWagonYards(id); @@ -216,7 +216,7 @@ export class TrainSchedulingController { @TrainSchedulingUpdate() @ApiOperation({ summary: - "Re-plan the yard this departure boards wagons from (schedule-only; physical yards untouched, dispatch requires alignment)", + "Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", }) updateScheduleWagonYards( @Param("id", ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts index 15d065d09..358c7a4db 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -1,4 +1,10 @@ -import { Capacity, CorridorBudget } from './corridor-capacity.util'; +import { + Capacity, + CorridorBudget, + orientStopsToSchedule, + stopYardsFor, + subtractCutWagons, +} from './corridor-capacity.util'; import { sizePartialOfferWagons } from './train-capacity.util'; describe('corridor-capacity.util — overage tolerance', () => { @@ -118,3 +124,99 @@ describe('corridor-capacity.util — overage tolerance', () => { }); }); }); + +describe('corridor-capacity.util — subtractCutWagons', () => { + const stops = ['a', 'b', 'c', 'd']; + const wagonsOnly: Capacity = { + wagons: 53, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }; + // 10 wagons cut at b, 13 cut at c, 30 ride through to d. + const cutPlan = Object.fromEntries([ + ...Array.from({ length: 10 }, (_, i) => [`w-b-${i}`, 'b']), + ...Array.from({ length: 13 }, (_, i) => [`w-c-${i}`, 'c']), + ]); + + const remaining = (budget: CorridorBudget, from: string, to: string): number => + budget.remainingFor(budget.legOf(from, to)!).wagons; + + it('debits each cut wagon from every edge at/after its cut stop', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + subtractCutWagons(budget, cutPlan); + expect(remaining(budget, 'a', 'b')).toBe(53); + expect(remaining(budget, 'a', 'c')).toBe(43); + expect(remaining(budget, 'b', 'c')).toBe(43); + expect(remaining(budget, 'a', 'd')).toBe(30); + expect(remaining(budget, 'c', 'd')).toBe(30); + }); + + it('stacks with per-booking subtraction on overlapping edges', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + subtractCutWagons(budget, cutPlan); + budget.subtract({ wagons: 5, weightTons: 0, lengthMeters: 0 }, budget.legOf('a', 'd')!); + expect(remaining(budget, 'a', 'b')).toBe(48); + expect(remaining(budget, 'c', 'd')).toBe(25); + }); + + it('ignores cut yards off the corridor and at the destination, and a missing plan', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + subtractCutWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' }); + subtractCutWagons(budget, null); + subtractCutWagons(budget, undefined); + expect(remaining(budget, 'a', 'd')).toBe(53); + }); + + it('works identically on an export-direction corridor — pure index math', () => { + // Export runs the other way geographically (Kality → Mojo → Doraleh); the + // stop LIST still runs origin→destination, so a cut at Mojo debits every + // edge from Mojo to Doraleh. Nothing in the math is import-specific. + const exportStops = ['kality', 'mojo', 'doraleh']; + const budget = new CorridorBudget(exportStops, wagonsOnly); + subtractCutWagons(budget, { 'w-1': 'mojo', 'w-2': 'mojo' }); + expect(remaining(budget, 'kality', 'mojo')).toBe(53); + expect(remaining(budget, 'mojo', 'doraleh')).toBe(51); + expect(remaining(budget, 'kality', 'doraleh')).toBe(51); + }); +}); + +describe('corridor-capacity.util — stop orientation and fallback', () => { + it('keeps a stop list that already runs origin→destination', () => { + expect(orientStopsToSchedule(['a', 'b', 'c'], 'a', 'c')).toEqual(['a', 'b', 'c']); + }); + + it('reverses a route traversed backwards (return-leg reuse) so cuts still land', () => { + // Milestones stored Doraleh→Mojo→Kality (the import route), reused by an + // export schedule Kality→Doraleh: without orientation every legOf() would + // return null and every cut silently no-op. + const oriented = orientStopsToSchedule( + ['doraleh', 'mojo', 'kality'], + 'kality', + 'doraleh', + ); + expect(oriented).toEqual(['kality', 'mojo', 'doraleh']); + const budget = new CorridorBudget(oriented, { + wagons: 10, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }); + subtractCutWagons(budget, { w: 'mojo' }); + expect(budget.remainingFor(budget.legOf('mojo', 'doraleh')!).wagons).toBe(9); + }); + + it('leaves a partially mismatched list untouched (unknown data keeps old behavior)', () => { + expect(orientStopsToSchedule(['x', 'y', 'z'], 'a', 'c')).toEqual(['x', 'y', 'z']); + }); + + it('stopYardsFor orients a backwards milestone list to the schedule endpoints', () => { + expect(stopYardsFor(['c', 'b', 'a'], 'a', 'c')).toEqual(['a', 'b', 'c']); + }); + + it('stopYardsFor keeps a single stray milestone as a middle stop', () => { + // Must agree with stopYardsForSchedule/mapScheduleStops: a one-milestone + // route offers that stop for cuts, in capacity AND validation alike. + expect(stopYardsFor(['m'], 'a', 'c')).toEqual(['a', 'm', 'c']); + expect(stopYardsFor([], 'a', 'c')).toEqual(['a', 'c']); + expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index f2602a157..3dbc8a2bf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -48,9 +48,38 @@ export function capacityFits(need: Capacity, budget: Capacity): boolean { } /** - * Ordered stop yard ids for a schedule. Route milestones (already ordered by - * sequence) when there are at least two; otherwise the schedule's own - * origin/destination pair — the legacy two-stop pseudo-route. + * Orient a milestone-derived stop list to THIS schedule's endpoints. + * + * Milestones run in the route's own direction (import and export routes each + * carry their own ordered sequence, so normally nothing changes). But a + * schedule pointed at a route traversed BACKWARDS (return-leg reuse) would + * otherwise silently break every index-based consumer — `legOf` returns null, + * `subtractCutWagons` no-ops, capacity oversells with zero signal. When the + * list plainly runs destination→origin, reverse it; anything else is left + * untouched (unknown data keeps today's behavior). + */ +export function orientStopsToSchedule( + stops: string[], + originStationId: string, + destinationStationId: string, +): string[] { + if ( + stops.length >= 2 && + stops[0] !== originStationId && + stops[0] === destinationStationId && + stops[stops.length - 1] === originStationId + ) { + return [...stops].reverse(); + } + return stops; +} + +/** + * Ordered stop yard ids for a schedule. Route milestones (ordered by + * sequence, oriented to the schedule's own endpoints — import and export + * both) when there are at least two; otherwise the schedule's own + * origin/destination pair around any stray milestone, so a one-milestone + * route keeps its middle stop (same shape as `stopYardsForSchedule`). */ export function stopYardsFor( milestoneYardIdsInOrder: string[] | null | undefined, @@ -58,9 +87,41 @@ export function stopYardsFor( destinationStationId: string, ): string[] { if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) { - return milestoneYardIdsInOrder; + return orientStopsToSchedule( + milestoneYardIdsInOrder, + originStationId, + destinationStationId, + ); + } + const raw = [ + originStationId, + ...(milestoneYardIdsInOrder ?? []), + destinationStationId, + ]; + const unique: string[] = []; + for (const yardId of raw) { + if (yardId && !unique.includes(yardId)) unique.push(yardId); + } + return unique; +} + +/** + * Debit the corridor for wagons staff cut mid-route: each cut wagon is gone + * from every edge at/after its cut stop ([cut, destination)). A cut yard not + * on this corridor — or equal to the destination — is ignored; validation in + * updateScheduleWagonYards owns rejecting it, and a fullLeg() fallback here + * would wrongly zero the whole route. + */ +export function subtractCutWagons( + budget: CorridorBudget, + cutPlan: Record | null | undefined, +): void { + if (!cutPlan) return; + const destination = budget.stops[budget.stops.length - 1]; + for (const cutYardId of Object.values(cutPlan)) { + const leg = budget.legOf(cutYardId, destination); + if (leg) budget.subtract({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg); } - return [originStationId, destinationStationId]; } /** Overage a locomotive may absorb beyond its base caps. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts index fa4b5e4c7..528e7a19c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts @@ -1,15 +1,30 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMaxSize, IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { ArrayMaxSize, IsArray, IsOptional, IsUUID, ValidateIf, ValidateNested } from 'class-validator'; export class ScheduleWagonYardMoveDto { @ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." }) @IsUUID() wagonId!: string; - @ApiProperty({ format: 'uuid', description: 'Pickup stop of the route this departure boards the wagon from.' }) + @ApiPropertyOptional({ + format: 'uuid', + description: 'Pickup stop of the route this departure boards the wagon from. Omit to leave unchanged.', + }) + @IsOptional() @IsUUID() - yardId!: string; + yardId?: string; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: + 'Drop stop this departure CUTS the wagon at (detached, left behind). null clears it — the wagon rides to the destination. Omit to leave unchanged.', + }) + @IsOptional() + @ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null) + @IsUUID() + cutYardId?: string | null; } export class UpdateScheduleWagonYardsDto { 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 beaaae384..35885f4cd 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 @@ -136,7 +136,11 @@ import { type ContainerPlacementInput, type WagonPlanSlot, } from '../utils/wagon-plan.util'; -import { CorridorBudget } from '../corridor-capacity.util'; +import { + CorridorBudget, + orientStopsToSchedule, + subtractCutWagons, +} from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { @@ -200,7 +204,7 @@ import { isPlaceholderContainerNumber, placementsForBookings, type ContainerUnitForPlacement, - occupiedTeuBySlot, + occupiedTeuPerEdgeBySlot, } from '../container-placement.util'; const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; @@ -1987,22 +1991,89 @@ export class TrainSchedulingService { (b) => b.freightType === 'CONTAINER', ); if (containerBookings.length) { - const units = expandBookingContainerUnits(containerBookings); + // Leg-aware fill: each unit carries its booking's stop-index span and + // each slot the span it rides, so TEU is counted per corridor edge — + // the same accounting the planner and the placement validator use. A + // whole-route walk on a leg-sharing train believed every wagon was + // full after one 40ft on ANY leg and dumped the leftovers onto the + // last wagon (#42), producing a wall of per-unit violations. + const previewStops = await this.stopYardsForSchedule(schedule); + const edgeCount = Math.max(1, previewStops.length - 1); + const legOfBooking = new Map( + preview.bookings.map((b) => { + const from = previewStops.indexOf(b.originYardId); + const to = previewStops.indexOf(b.destinationYardId); + return [ + b.id, + from >= 0 && to > from ? { from, to } : { from: 0, to: edgeCount }, + ] as const; + }), + ); + const units = expandBookingContainerUnits(containerBookings).map((u) => ({ + ...u, + leg: legOfBooking.get(u.bookingId), + })); + const slotSpans = preview.wagonPlan + .filter( + (s) => + s.slotLoadType === 'CONTAINER' || + s.allocations.some((a) => a.loadType === AllocationLoadType.Container), + ) + .map((s) => { + const from = s.boardYardId ? previewStops.indexOf(s.boardYardId) : 0; + const toIdx = s.alightYardId ? previewStops.indexOf(s.alightYardId) : -1; + return { + sequenceNo: s.sequenceNo, + from: from >= 0 ? from : 0, + to: toIdx > 0 ? toIdx : edgeCount, + }; + }); + // Caller-provided placements can be STALE: the workspace pins container + // positions against the plan it last fetched, and every (re)assignment + // rebuilds the plan with fresh sequence numbers (remove + re-add being + // the common case). A pin pointing at a slot that no longer exists must + // not poison the fill — drop it and auto-place its unit instead; the + // placement validator still checks whatever survives. + const validSeq = new Set(slotSpans.map((s) => s.sequenceNo)); + const provided = (containerPlacements ?? []).filter((p) => + validSeq.has(p.sequenceNo), + ); + const droppedStale = (containerPlacements ?? []).length - provided.length; + if (droppedStale > 0) { + this.logger.warn( + `[assign ${scheduleId}] dropped ${droppedStale} stale container placement(s) ` + + `pointing at slots not in the rebuilt plan — re-auto-filling those units`, + ); + } const providedKeys = new Set( - (containerPlacements ?? []).map( - (p) => `${p.bookingContainerId}:${p.unitIndex}`, - ), + provided.map((p) => `${p.bookingContainerId}:${p.unitIndex}`), ); const unplacedUnits = units.filter( (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`), ); - if (unplacedUnits.length) { - const slots = getContainerSlotSequenceNos(preview.wagonPlan); - const generated = autoFillPlacements( + if (unplacedUnits.length || droppedStale > 0) { + const { placements: generated, overflow } = autoFillPlacements( unplacedUnits, - slots, - occupiedTeuBySlot(containerPlacements ?? [], units), + slotSpans, + occupiedTeuPerEdgeBySlot(provided, units, edgeCount), + edgeCount, ); + if (overflow.length) { + // One honest message, grouped per booking — not one violation per + // container piled onto the same wagon. + const byRef = new Map(); + for (const u of overflow) { + const ref = u.bookingReference ?? u.bookingId; + byRef.set(ref, (byRef.get(ref) ?? 0) + 1); + } + const detail = [...byRef.entries()] + .map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`) + .join('; '); + throw new BadRequestException({ + message: `Booking validation failed: ${detail} — the train's container wagons are full on the booking's leg`, + violations: [detail], + }); + } const missing = findMissingContainerNumberIssues(unplacedUnits, generated); if (missing.length) { throw new BadRequestException({ @@ -2012,7 +2083,7 @@ export class TrainSchedulingService { violations: missing.map((m) => m.issue), }); } - containerPlacements = [...(containerPlacements ?? []), ...generated]; + containerPlacements = [...provided, ...generated]; } } } @@ -4328,6 +4399,38 @@ export class TrainSchedulingService { const passedYardIds = stations .filter((s) => s.sequenceNo <= dto.sequenceNo) .map((s) => s.yardId); + // Wagons planned to CUT at a stop the train has now passed detach + // here: position freezes at the cut yard and they stop riding the + // position fix below (its filter is current_train_schedule_id). + // Cargo-carrying ones were already settled by autoUnloadAtYard above + // (validation forbids cargo booked past the cut) — anything still + // bound to the schedule is riding empty. Matching against ALL passed + // yards, not just this one, self-heals skipped checkpoint logs. + const cutPlan = schedule.plannedWagonCutYards ?? {}; + const cutNow = Object.entries(cutPlan).filter(([, yardId]) => + passedYardIds.includes(yardId), + ); + for (const [wagonId, cutYardId] of cutNow) { + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + // Already settled earlier (or re-pinned elsewhere) — not ours to move. + if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; + await manager.getRepository(Wagon).update(wagonId, { + currentYardId: cutYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId, + toYardId: cutYardId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt, + }), + ); + } await manager .getRepository(Wagon) .createQueryBuilder() @@ -4546,9 +4649,14 @@ export class TrainSchedulingService { // A wagon that already alighted mid-route (unload released it, possibly // re-pinned elsewhere since) is no longer this schedule's to move. if (wagon.currentTrainScheduleId !== scheduleId) continue; - // Dynamic consist: the wagon settles at its slot's alight yard, not - // blanket at the train's destination. - const settleYardId = slot.alightYardId ?? schedule.destinationStationId; + // Dynamic consist: the wagon settles at its planned cut yard first, + // then its slot's alight yard — never blanket at the train's + // destination. Covers journeys logged with only a final arrival: cut + // wagons still settle at their cut yard instead of teleporting to it. + const settleYardId = + schedule.plannedWagonCutYards?.[wagon.id] ?? + slot.alightYardId ?? + schedule.destinationStationId; await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5566,6 +5674,7 @@ export class TrainSchedulingService { const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; const plannedYards = pinSchedule?.plannedWagonYards ?? {}; + const cutPlan = pinSchedule?.plannedWagonCutYards ?? {}; const unpinnable = this.findUnpinnableWagonSlots( planSlots, @@ -5576,6 +5685,7 @@ export class TrainSchedulingService { pinnedToScheduleIds, stops, plannedYards, + cutPlan, ); if (unpinnable.length) { throw new BadRequestException({ @@ -5598,6 +5708,8 @@ export class TrainSchedulingService { pinnedToScheduleIds, reverseWagonOrder, plannedYards, + cutPlan, + stops, ); if (!physical) continue; @@ -5648,6 +5760,7 @@ export class TrainSchedulingService { pinnedToScheduleIds, stops, targetSchedule?.plannedWagonYards ?? {}, + targetSchedule?.plannedWagonCutYards ?? {}, ); } @@ -5681,6 +5794,7 @@ export class TrainSchedulingService { pinnedToScheduleIds: Set = new Set(), stops: string[] = [], plannedYards: PlannedWagonYards = {}, + cutPlan: Record = {}, ): string[] { const violations: string[] = []; // One physical wagon may serve several slots whose leg spans don't overlap @@ -5701,6 +5815,8 @@ export class TrainSchedulingService { pinnedToScheduleIds, false, plannedYards, + cutPlan, + stops, ); if (!physical) { violations.push( @@ -5732,7 +5848,17 @@ export class TrainSchedulingService { pinnedToScheduleIds: Set = new Set(), reverseWagonOrder = false, plannedYards: PlannedWagonYards = {}, + cutPlan: Record = {}, + stops: string[] = [], ): Wagon | undefined { + // How far down the route a wagon rides before this schedule cuts it: + // stop index of its cut yard, or the last stop when uncut (also when the + // cut yard is unknown to this stop list — conservative full reach). + const reachIdxOf = (wagonId: string): number => { + const cutYardId = cutPlan[wagonId]; + const idx = cutYardId ? stops.indexOf(cutYardId) : -1; + return idx >= 0 ? idx : Math.max(1, stops.length - 1); + }; // Free for this slot = no already-assigned span on this wagon overlaps the // slot's own leg. Disjoint legs (alight before board) share the wagon. const spanFree = (wagonId: string): boolean => @@ -5778,9 +5904,15 @@ export class TrainSchedulingService { w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && spanFree(w.id) && + // A wagon cut before the slot's alight stop cannot serve it. + reachIdxOf(w.id) >= span[1] && (!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId), ) .sort((a, b) => { + // Tightest sufficient reach first: cut-at-B wagons soak up A→B slots + // so full-route wagons stay free for slots that ride to the end. + const reachDelta = reachIdxOf(a.id) - reachIdxOf(b.id); + if (reachDelta !== 0) return reachDelta; if (a.sequenceNumber == null || b.sequenceNumber == null) { return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0); } @@ -6036,6 +6168,36 @@ export class TrainSchedulingService { } } + /** + * Whole-route auto-fill for the secondary flows (previews, single-booking + * add): units that fit nowhere raise ONE grouped, human-readable error — + * never a clamp onto the last wagon that the placement validator then + * rejects once per container. Conservative on leg-sharing trains (treats a + * wagon's TEU as global), so it can say "full" where the main assign path's + * leg-aware fill would still fit — never the reverse. + */ + private autoFillOrFail( + units: ContainerUnitForPlacement[], + slots: number[], + ): ContainerPlacementInput[] { + const { placements, overflow } = autoFillPlacements(units, slots); + if (overflow.length) { + const byRef = new Map(); + for (const u of overflow) { + const ref = u.bookingReference ?? u.bookingId; + byRef.set(ref, (byRef.get(ref) ?? 0) + 1); + } + const detail = [...byRef.entries()] + .map(([ref, n]) => `${ref}: ${n} container(s) have no wagon space left`) + .join('; '); + throw new BadRequestException({ + message: `Booking validation failed: ${detail} — the train's container wagons are full`, + violations: [detail], + }); + } + return placements; + } + private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, @@ -6405,6 +6567,7 @@ export class TrainSchedulingService { const stops = this.mapScheduleStops(schedule); const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId)); const plan = schedule.plannedWagonYards ?? {}; + const cutPlan = schedule.plannedWagonCutYards ?? {}; const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true, currentYard: true }, @@ -6432,6 +6595,7 @@ export class TrainSchedulingService { const rows = wagons.map((w) => { const plannedYardId = scheduleYardOf(plan, w); + const cutYardId = cutPlan[w.id] ?? null; const locked = lockedIds.has(w.id); return { id: w.id, @@ -6444,6 +6608,8 @@ export class TrainSchedulingService { physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null, plannedYardId, plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null, + cutYardId, + cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null, aligned: plannedYardId === w.currentYardId, locked, lockReason: locked ? 'Carries cargo booked on this schedule' : null, @@ -6455,6 +6621,7 @@ export class TrainSchedulingService { pickup: pickupYardIds.has(s.yardId), planned: rows.filter((r) => r.plannedYardId === s.yardId).length, physical: rows.filter((r) => r.physicalYardId === s.yardId).length, + cut: rows.filter((r) => r.cutYardId === s.yardId).length, })); return { scheduleId, @@ -6467,14 +6634,16 @@ export class TrainSchedulingService { } /** - * Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED - * schedules, only the train's own wagons, only pickup stops of the route, - * never a wagon already carrying this schedule's cargo. Physical yards are - * untouched — the train builder owns those. + * Re-plan which yard this departure boards wagons from (`yardId`) and/or + * where it cuts them mid-route (`cutYardId`; null clears — the wagon rides + * to the destination). Only DRAFT/SCHEDULED schedules, only the train's own + * wagons; boarding only at pickup stops, cutting only at drop stops after + * the boarding yard and never before allocated cargo's destination. + * Physical yards are untouched — the train builder owns those. */ async updateScheduleWagonYards( scheduleId: string, - moves: Array<{ wagonId: string; yardId: string }>, + moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>, ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -6505,27 +6674,76 @@ export class TrainSchedulingService { .map((slot) => slot.physicalWagonId as string), ); + const stopIdx = new Map(stops.map((s, i) => [s.yardId, i])); + const dropYardIds = new Set(stops.slice(1).map((s) => s.yardId)); + // Furthest stop any allocated cargo rides to, per physical wagon — a cut + // must not strand cargo short of its destination (equal is fine: cargo + // alights there, then the wagon is cut). + const maxCargoDestIdx = new Map(); + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId) continue; + for (const alloc of slot.allocations ?? []) { + const dest = alloc.booking?.destinationYardId; + const idx = dest != null ? stopIdx.get(dest) : undefined; + if (idx == null) continue; + const prev = maxCargoDestIdx.get(slot.physicalWagonId) ?? -1; + if (idx > prev) maxCargoDestIdx.set(slot.physicalWagonId, idx); + } + } + const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) }; + const cutPlan: Record = { ...(schedule.plannedWagonCutYards ?? {}) }; for (const move of moves) { const wagon = wagonById.get(move.wagonId); if (!wagon) { throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`); } - if (!pickupYardIds.has(move.yardId)) { - throw new BadRequestException( - `Yard ${move.yardId} is not a pickup stop of this schedule's route`, - ); + if (move.yardId !== undefined) { + if (!pickupYardIds.has(move.yardId)) { + throw new BadRequestException( + `Yard ${move.yardId} is not a pickup stop of this schedule's route`, + ); + } + if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`, + ); + } + plan[wagon.id] = move.yardId; } - if (lockedIds.has(wagon.id) && scheduleYardOf(plan, wagon) !== move.yardId) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`, - ); + if (move.cutYardId === null) { + delete cutPlan[wagon.id]; + } else if (move.cutYardId !== undefined) { + if (!dropYardIds.has(move.cutYardId)) { + throw new BadRequestException( + `Yard ${move.cutYardId} is not a drop stop of this schedule's route`, + ); + } + cutPlan[wagon.id] = move.cutYardId; + } + // Validate the combined final plan: boarding must precede the cut, + // whichever side this move changed. + const cutYardId = cutPlan[wagon.id]; + if (cutYardId !== undefined) { + const boardYardId = scheduleYardOf(plan, wagon); + const boardIdx = boardYardId != null ? stopIdx.get(boardYardId) ?? 0 : 0; + const cutIdx = stopIdx.get(cutYardId) as number; + if (cutIdx <= boardIdx) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber}: cut yard must come after its boarding yard on the route`, + ); + } + const cargoIdx = maxCargoDestIdx.get(wagon.id); + if (cargoIdx != null && cutIdx < cargoIdx) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} carries cargo booked to ${stops[cargoIdx].label} and cannot be cut earlier`, + ); + } } - plan[wagon.id] = move.yardId; } await this.dataSource .getRepository(TrainSchedule) - .update(scheduleId, { plannedWagonYards: plan }); + .update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan }); // ponytail: per-stop over-booking check counts bookings boarding at the // stop against wagons planned there, ignoring leg sharing — a warning, not @@ -7953,6 +8171,8 @@ export class TrainSchedulingService { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }); + // Wagons staff plan to cut mid-route are gone from every edge past the cut. + subtractCutWagons(budget, schedule.plannedWagonCutYards); for (const sb of schedule.scheduleBookings ?? []) { if (!sb.booking) continue; budget.subtract( @@ -8280,8 +8500,14 @@ export class TrainSchedulingService { .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); milestoneYards = milestones.map((m) => m.yardId); } + // Oriented to THIS schedule's endpoints: a route traversed backwards + // (return-leg reuse) must not silently no-op every cut/leg lookup. const raw = milestoneYards.length >= 2 - ? milestoneYards + ? orientStopsToSchedule( + milestoneYards, + schedule.originStationId, + schedule.destinationStationId, + ) : [schedule.originStationId, ...milestoneYards, schedule.destinationStationId]; const unique: string[] = []; for (const yardId of raw) { @@ -8973,27 +9199,44 @@ export class TrainSchedulingService { const milestones = [...(schedule.route?.milestones ?? [])].sort( (a, b) => a.sequenceNo - b.sequenceNo, ); - const raw = milestones.length >= 2 - ? milestones.map((m) => ({ - yardId: m.yardId, - label: m.yard?.label ?? m.yard?.code ?? m.yardId, - })) - : [ - { - yardId: schedule.originStationId, - label: - schedule.originStation?.label ?? - schedule.originStation?.code ?? - schedule.originStationId, - }, - { - yardId: schedule.destinationStationId, - label: - schedule.destinationStation?.label ?? - schedule.destinationStation?.code ?? - schedule.destinationStationId, - }, - ]; + const milestoneStops = milestones.map((m) => ({ + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? m.yardId, + })); + // Same stop shape and orientation as stopYardsForSchedule / stopYardsFor — + // the three builders MUST agree, or validation rejects cuts that capacity + // would honour. Short routes keep a stray milestone as a middle stop; a + // backwards-traversed route is oriented to this schedule's endpoints. + let raw: Array<{ yardId: string; label: string }>; + if (milestoneStops.length >= 2) { + const oriented = orientStopsToSchedule( + milestoneStops.map((s) => s.yardId), + schedule.originStationId, + schedule.destinationStationId, + ); + raw = + oriented[0] === milestoneStops[0]?.yardId + ? milestoneStops + : [...milestoneStops].reverse(); + } else { + raw = [ + { + yardId: schedule.originStationId, + label: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + schedule.originStationId, + }, + ...milestoneStops, + { + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + schedule.destinationStationId, + }, + ]; + } const seen = new Set(); return raw.filter((stop) => { if (!stop.yardId || seen.has(stop.yardId)) return false; @@ -9090,7 +9333,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingForBooking = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === bookingId, ); @@ -9222,7 +9465,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingForGov = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === governmentBookingId, ); @@ -9360,7 +9603,7 @@ export class TrainSchedulingService { const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + const placements = this.autoFillOrFail(units, slots); const missingNumbers = findMissingContainerNumberIssues(units, placements); const missingByBooking = new Map(); for (const m of missingNumbers) { @@ -10062,7 +10305,20 @@ export class TrainSchedulingService { if (containerBookings.some((b) => b.id === booking.id)) { const units = expandBookingContainerUnits(containerBookings); const slots = getContainerSlotSequenceNos(validation.wagonPlan); - const placements = autoFillPlacements(units, slots); + // Availability probe — never throws. Overflow reads as "cannot assign", + // not a 500 on the board. + const { placements, overflow } = autoFillPlacements(units, slots); + const overflowHere = overflow.filter((u) => u.bookingId === booking.id).length; + if (overflowHere > 0) { + return { + wagonsRequired, + requiredWagonTypeCode, + yardWagonsAvailable, + canAssign: false, + blockReason: `${overflowHere} container(s) have no wagon space left on this train`, + shortage: null, + }; + } const missing = findMissingContainerNumberIssues(units, placements).find( (m) => m.bookingId === booking.id, ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx index 3e6786a84..b09cf2a37 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx @@ -54,6 +54,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { /** wagonId → yardId queued but not yet saved. */ const [pending, setPending] = useState>({}); + /** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */ + const [pendingCut, setPendingCut] = useState>({}); const [bulkType, setBulkType] = useState(null); const [bulkFrom, setBulkFrom] = useState(null); const [bulkTo, setBulkTo] = useState(null); @@ -70,6 +72,32 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { "—"; const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId; + const effectiveCut = (w: ScheduleWagonYardRow) => + w.id in pendingCut ? pendingCut[w.id] : w.cutYardId; + const stopIndexOf = (yardId: string | null) => + yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId); + /** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after + * boarding, excluding the destination (that's the cleared/default state). */ + const cutOptionsFor = (boardYardId: string | null) => { + const stops = data?.stops ?? []; + const boardIdx = Math.max(0, stopIndexOf(boardYardId)); + return stops + .slice(boardIdx + 1, stops.length - 1) + .map((s) => ({ value: s.yardId, label: s.label })); + }; + /** Board-yard changes can invalidate a cut (server rejects cut ≤ board) — queue a clear. */ + const clearInvalidCut = ( + next: Record, + w: ScheduleWagonYardRow, + boardYardId: string | null, + ) => { + const cut = w.id in next ? next[w.id] : w.cutYardId; + if (cut != null && stopIndexOf(cut) <= stopIndexOf(boardYardId)) { + if (w.cutYardId == null) delete next[w.id]; + else next[w.id] = null; + } + return next; + }; const perStop = useMemo( () => @@ -77,8 +105,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { ...s, planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId) .length, + cut: (data?.wagons ?? []).filter( + (w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId, + ).length, })), - [data, pending], + [data, pending, pendingCut], ); const typeOptions = useMemo(() => { const seen = new Map(); @@ -86,7 +117,7 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { return [...seen].map(([value, label]) => ({ value, label })); }, [data]); - const pendingCount = Object.keys(pending).length; + const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size; const queueBulk = () => { if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return; @@ -111,18 +142,29 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { } return next; }); + setPendingCut((prev) => { + let next = { ...prev }; + for (const w of picked) next = clearInvalidCut(next, w, bulkTo); + return next; + }); }; const handleSave = async () => { if (!pendingCount) return; try { + const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])]; const result = await save.mutateAsync({ scheduleId, payload: { - moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })), + moves: wagonIds.map((wagonId) => ({ + wagonId, + ...(wagonId in pending ? { yardId: pending[wagonId] } : {}), + ...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}), + })), }, }); setPending({}); + setPendingCut({}); toast({ title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`, description: result.warnings.length ? result.warnings.join(" ") : undefined, @@ -153,8 +195,10 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { } variant="light"> Planned = where this departure boards the wagon (what customers can book per - origin). Physical = where the wagon stands now (train builder). Dispatch is blocked - until every wagon stands at its planned yard. + origin). Physical = where the wagon stands now (train builder). Cut at ={" "} + where this departure detaches the wagon and leaves it — blank means it rides to the + destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until + every wagon stands at its planned yard. {data.misaligned > 0 ? ( {" "} @@ -183,6 +227,16 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { Physical {s.physical} + {s.cut > 0 ? ( + + Cut {s.cut} + + ) : null} + {!s.pickup ? ( + + Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)} + + ) : null} ))} @@ -229,13 +283,15 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { Type Physical yard Planned yard (this schedule) + Cut at (rides to) Status {data.wagons.map((w) => { const planned = effectiveYard(w); - const changed = w.id in pending; + const cut = effectiveCut(w); + const changed = w.id in pending || w.id in pendingCut; return ( {w.sequenceNumber ?? "—"} @@ -252,14 +308,17 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { size="xs" data={yardOptions} value={planned} - onChange={(v) => + onChange={(v) => { setPending((prev) => { const next = { ...prev }; if (!v || v === w.plannedYardId) delete next[w.id]; else next[w.id] = v; return next; - }) - } + }); + setPendingCut((prev) => + clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId), + ); + }} w={180} /> ) : ( @@ -273,6 +332,30 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { )} + + {editable ? ( + // Locked wagons stay editable here — the server enforces the + // cargo-destination floor and the toast explains a 409. +