mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1389 from Tria-plc/freight_feature/usermanagement
fix issue
This commit is contained in:
@@ -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<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_wagon_cut_yards jsonb
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_cut_yards
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,6 +130,15 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
|
@Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true })
|
||||||
plannedWagonYards?: Record<string, string> | null;
|
plannedWagonYards?: Record<string, string> | 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<string, string> | null;
|
||||||
|
|
||||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||||
bookingWindowStatus!: string;
|
bookingWindowStatus!: string;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import {
|
|||||||
CorridorLeg,
|
CorridorLeg,
|
||||||
OverageTolerance,
|
OverageTolerance,
|
||||||
stopYardsFor,
|
stopYardsFor,
|
||||||
|
subtractCutWagons,
|
||||||
} from './corridor-capacity.util';
|
} from './corridor-capacity.util';
|
||||||
import { WagonStockLedger } from './wagon-stock-ledger.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.
|
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||||
const stops = await this.stopsForSchedule(schedule);
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
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)) {
|
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||||
budget.subtract(
|
budget.subtract(
|
||||||
this.needFor(b, wagonDims),
|
this.needFor(b, wagonDims),
|
||||||
|
|||||||
@@ -86,6 +86,40 @@ describe('BookingBatchService.smartBulkNeed', () => {
|
|||||||
expect(call(booking(695), stock, contested)).toBeNull();
|
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<string, number>,
|
||||||
|
) => { 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)', () => {
|
it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => {
|
||||||
const stock = new WagonStockLedger(
|
const stock = new WagonStockLedger(
|
||||||
new Map([
|
new Map([
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
autoFillPlacements,
|
autoFillPlacements,
|
||||||
findMissingContainerNumberIssues,
|
findMissingContainerNumberIssues,
|
||||||
|
occupiedTeuPerEdgeBySlot,
|
||||||
type ContainerUnitForPlacement,
|
type ContainerUnitForPlacement,
|
||||||
} from './container-placement.util';
|
} from './container-placement.util';
|
||||||
|
|
||||||
@@ -27,14 +28,15 @@ describe('container-placement.util', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it('auto-fills placements across slots', () => {
|
it('auto-fills placements across slots', () => {
|
||||||
const placements = autoFillPlacements(units, [1, 2]);
|
const { placements, overflow } = autoFillPlacements(units, [1, 2]);
|
||||||
expect(placements).toHaveLength(2);
|
expect(placements).toHaveLength(2);
|
||||||
|
expect(overflow).toHaveLength(0);
|
||||||
expect(placements[0].sequenceNo).toBe(1);
|
expect(placements[0].sequenceNo).toBe(1);
|
||||||
expect(placements[1].sequenceNo).toBe(2);
|
expect(placements[1].sequenceNo).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports missing container numbers only when placement is empty', () => {
|
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);
|
const issues = findMissingContainerNumberIssues(units, placements);
|
||||||
expect(issues).toHaveLength(0);
|
expect(issues).toHaveLength(0);
|
||||||
expect(placements[1].containerNumber).toMatch(/^TBD-/);
|
expect(placements[1].containerNumber).toMatch(/^TBD-/);
|
||||||
@@ -53,7 +55,84 @@ describe('container-placement.util', () => {
|
|||||||
containerNumber: null,
|
containerNumber: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const placements = autoFillPlacements(single, [1]);
|
const { placements } = autoFillPlacements(single, [1]);
|
||||||
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export type ContainerUnitForPlacement = {
|
|||||||
teuSlots?: number;
|
teuSlots?: number;
|
||||||
sizeFt?: number;
|
sizeFt?: number;
|
||||||
containerNumber?: string | null;
|
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 {
|
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||||
@@ -25,51 +37,118 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
|
|||||||
return trimmed || placeholderContainerNumber(unit);
|
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(
|
export function autoFillPlacements(
|
||||||
units: ContainerUnitForPlacement[],
|
units: ContainerUnitForPlacement[],
|
||||||
containerSlots: number[],
|
containerSlots: ReadonlyArray<number | ContainerSlotForPlacement>,
|
||||||
/**
|
/**
|
||||||
* TEU already taken per slot sequenceNo by placements the caller supplied.
|
* 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
|
* A plain number occupies every edge of the slot; an array is per-edge.
|
||||||
* 40ft onto a wagon another booking's placement had already filled.
|
|
||||||
*/
|
*/
|
||||||
occupiedTeuBySlot: ReadonlyMap<number, number> = new Map(),
|
occupiedTeuBySlot: ReadonlyMap<number, number | readonly number[]> = new Map(),
|
||||||
): ContainerPlacementInput[] {
|
edgeCount = 1,
|
||||||
if (!units.length || !containerSlots.length) return [];
|
): { 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 placements: ContainerPlacementInput[] = [];
|
||||||
|
const overflow: ContainerUnitForPlacement[] = [];
|
||||||
|
if (!units.length) return { placements, overflow };
|
||||||
|
if (!slots.length) return { placements, overflow: [...units] };
|
||||||
|
|
||||||
const MAX_TEU_PER_WAGON = 2;
|
const MAX_TEU_PER_WAGON = 2;
|
||||||
let currentSlotIndex = 0;
|
const used = new Map<number, number[]>();
|
||||||
let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0;
|
const usedRow = (sequenceNo: number): number[] => {
|
||||||
|
let row = used.get(sequenceNo);
|
||||||
|
if (!row) {
|
||||||
|
const seed = occupiedTeuBySlot.get(sequenceNo) ?? 0;
|
||||||
|
row =
|
||||||
|
typeof seed === 'number'
|
||||||
|
? new Array<number>(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) {
|
for (const unit of units) {
|
||||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||||
|
const leg = legOf(unit);
|
||||||
while (
|
const slot = slots.find((s) => {
|
||||||
teuInCurrentSlot > 0 &&
|
if (s.from > leg.from || leg.to > s.to) return false;
|
||||||
teuInCurrentSlot + teu > MAX_TEU_PER_WAGON &&
|
const row = usedRow(s.sequenceNo);
|
||||||
currentSlotIndex < containerSlots.length - 1
|
for (let e = leg.from; e < leg.to; e += 1) {
|
||||||
) {
|
if ((row[e] ?? 0) + teu > MAX_TEU_PER_WAGON) return false;
|
||||||
currentSlotIndex += 1;
|
}
|
||||||
teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0;
|
return true;
|
||||||
|
});
|
||||||
|
if (!slot) {
|
||||||
|
overflow.push(unit);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
const row = usedRow(slot.sequenceNo);
|
||||||
const sequenceNo =
|
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + teu;
|
||||||
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
|
|
||||||
containerSlots[containerSlots.length - 1] ??
|
|
||||||
containerSlots[0];
|
|
||||||
|
|
||||||
placements.push({
|
placements.push({
|
||||||
bookingContainerId: unit.bookingContainerId,
|
bookingContainerId: unit.bookingContainerId,
|
||||||
unitIndex: unit.unitIndex,
|
unitIndex: unit.unitIndex,
|
||||||
sequenceNo,
|
sequenceNo: slot.sequenceNo,
|
||||||
containerNumber: resolveContainerNumber(unit),
|
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<number, number[]> {
|
||||||
|
const edges = Math.max(1, edgeCount);
|
||||||
|
const unitByKey = new Map(units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u]));
|
||||||
|
const out = new Map<number, number[]>();
|
||||||
|
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<number>(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. */
|
/** TEU per slot sequenceNo consumed by the given placements. */
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export class TrainSchedulingController {
|
|||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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) {
|
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.getScheduleWagonYards(id);
|
return this.trainSchedulingService.getScheduleWagonYards(id);
|
||||||
@@ -216,7 +216,7 @@ export class TrainSchedulingController {
|
|||||||
@TrainSchedulingUpdate()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
updateScheduleWagonYards(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -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';
|
import { sizePartialOfferWagons } from './train-capacity.util';
|
||||||
|
|
||||||
describe('corridor-capacity.util — overage tolerance', () => {
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,9 +48,38 @@ export function capacityFits(need: Capacity, budget: Capacity): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered stop yard ids for a schedule. Route milestones (already ordered by
|
* Orient a milestone-derived stop list to THIS schedule's endpoints.
|
||||||
* sequence) when there are at least two; otherwise the schedule's own
|
*
|
||||||
* origin/destination pair — the legacy two-stop pseudo-route.
|
* 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(
|
export function stopYardsFor(
|
||||||
milestoneYardIdsInOrder: string[] | null | undefined,
|
milestoneYardIdsInOrder: string[] | null | undefined,
|
||||||
@@ -58,9 +87,41 @@ export function stopYardsFor(
|
|||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
|
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<string, string> | 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. */
|
/** Overage a locomotive may absorb beyond its base caps. */
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class ScheduleWagonYardMoveDto {
|
||||||
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
|
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
wagonId!: string;
|
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()
|
@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 {
|
export class UpdateScheduleWagonYardsDto {
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ import {
|
|||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} 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 { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||||||
import {
|
import {
|
||||||
@@ -200,7 +204,7 @@ import {
|
|||||||
isPlaceholderContainerNumber,
|
isPlaceholderContainerNumber,
|
||||||
placementsForBookings,
|
placementsForBookings,
|
||||||
type ContainerUnitForPlacement,
|
type ContainerUnitForPlacement,
|
||||||
occupiedTeuBySlot,
|
occupiedTeuPerEdgeBySlot,
|
||||||
} from '../container-placement.util';
|
} from '../container-placement.util';
|
||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||||
@@ -1987,22 +1991,89 @@ export class TrainSchedulingService {
|
|||||||
(b) => b.freightType === 'CONTAINER',
|
(b) => b.freightType === 'CONTAINER',
|
||||||
);
|
);
|
||||||
if (containerBookings.length) {
|
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(
|
const providedKeys = new Set(
|
||||||
(containerPlacements ?? []).map(
|
provided.map((p) => `${p.bookingContainerId}:${p.unitIndex}`),
|
||||||
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const unplacedUnits = units.filter(
|
const unplacedUnits = units.filter(
|
||||||
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||||||
);
|
);
|
||||||
if (unplacedUnits.length) {
|
if (unplacedUnits.length || droppedStale > 0) {
|
||||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
const { placements: generated, overflow } = autoFillPlacements(
|
||||||
const generated = autoFillPlacements(
|
|
||||||
unplacedUnits,
|
unplacedUnits,
|
||||||
slots,
|
slotSpans,
|
||||||
occupiedTeuBySlot(containerPlacements ?? [], units),
|
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<string, number>();
|
||||||
|
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);
|
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -2012,7 +2083,7 @@ export class TrainSchedulingService {
|
|||||||
violations: missing.map((m) => m.issue),
|
violations: missing.map((m) => m.issue),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
containerPlacements = [...provided, ...generated];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4328,6 +4399,38 @@ export class TrainSchedulingService {
|
|||||||
const passedYardIds = stations
|
const passedYardIds = stations
|
||||||
.filter((s) => s.sequenceNo <= dto.sequenceNo)
|
.filter((s) => s.sequenceNo <= dto.sequenceNo)
|
||||||
.map((s) => s.yardId);
|
.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
|
await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
@@ -4546,9 +4649,14 @@ export class TrainSchedulingService {
|
|||||||
// A wagon that already alighted mid-route (unload released it, possibly
|
// A wagon that already alighted mid-route (unload released it, possibly
|
||||||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||||
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||||
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
// Dynamic consist: the wagon settles at its planned cut yard first,
|
||||||
// blanket at the train's destination.
|
// then its slot's alight yard — never blanket at the train's
|
||||||
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
// 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, {
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
currentTrainScheduleId: null,
|
currentTrainScheduleId: null,
|
||||||
trainSetWagonId: null,
|
trainSetWagonId: null,
|
||||||
@@ -5566,6 +5674,7 @@ export class TrainSchedulingService {
|
|||||||
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
||||||
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
|
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
|
||||||
|
const cutPlan = pinSchedule?.plannedWagonCutYards ?? {};
|
||||||
|
|
||||||
const unpinnable = this.findUnpinnableWagonSlots(
|
const unpinnable = this.findUnpinnableWagonSlots(
|
||||||
planSlots,
|
planSlots,
|
||||||
@@ -5576,6 +5685,7 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
stops,
|
stops,
|
||||||
plannedYards,
|
plannedYards,
|
||||||
|
cutPlan,
|
||||||
);
|
);
|
||||||
if (unpinnable.length) {
|
if (unpinnable.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -5598,6 +5708,8 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
reverseWagonOrder,
|
reverseWagonOrder,
|
||||||
plannedYards,
|
plannedYards,
|
||||||
|
cutPlan,
|
||||||
|
stops,
|
||||||
);
|
);
|
||||||
if (!physical) continue;
|
if (!physical) continue;
|
||||||
|
|
||||||
@@ -5648,6 +5760,7 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
stops,
|
stops,
|
||||||
targetSchedule?.plannedWagonYards ?? {},
|
targetSchedule?.plannedWagonYards ?? {},
|
||||||
|
targetSchedule?.plannedWagonCutYards ?? {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5681,6 +5794,7 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds: Set<string> = new Set(),
|
pinnedToScheduleIds: Set<string> = new Set(),
|
||||||
stops: string[] = [],
|
stops: string[] = [],
|
||||||
plannedYards: PlannedWagonYards = {},
|
plannedYards: PlannedWagonYards = {},
|
||||||
|
cutPlan: Record<string, string> = {},
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
// One physical wagon may serve several slots whose leg spans don't overlap
|
// One physical wagon may serve several slots whose leg spans don't overlap
|
||||||
@@ -5701,6 +5815,8 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds,
|
pinnedToScheduleIds,
|
||||||
false,
|
false,
|
||||||
plannedYards,
|
plannedYards,
|
||||||
|
cutPlan,
|
||||||
|
stops,
|
||||||
);
|
);
|
||||||
if (!physical) {
|
if (!physical) {
|
||||||
violations.push(
|
violations.push(
|
||||||
@@ -5732,7 +5848,17 @@ export class TrainSchedulingService {
|
|||||||
pinnedToScheduleIds: Set<string> = new Set(),
|
pinnedToScheduleIds: Set<string> = new Set(),
|
||||||
reverseWagonOrder = false,
|
reverseWagonOrder = false,
|
||||||
plannedYards: PlannedWagonYards = {},
|
plannedYards: PlannedWagonYards = {},
|
||||||
|
cutPlan: Record<string, string> = {},
|
||||||
|
stops: string[] = [],
|
||||||
): Wagon | undefined {
|
): 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
|
// 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.
|
// slot's own leg. Disjoint legs (alight before board) share the wagon.
|
||||||
const spanFree = (wagonId: string): boolean =>
|
const spanFree = (wagonId: string): boolean =>
|
||||||
@@ -5778,9 +5904,15 @@ export class TrainSchedulingService {
|
|||||||
w.trainId === builtTrainId &&
|
w.trainId === builtTrainId &&
|
||||||
w.wagonTypeId === slot.wagonTypeId &&
|
w.wagonTypeId === slot.wagonTypeId &&
|
||||||
spanFree(w.id) &&
|
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),
|
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.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) {
|
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||||
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
|
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<string, number>();
|
||||||
|
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(
|
private async persistTrainSetWagons(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
trainSetId: string,
|
trainSetId: string,
|
||||||
@@ -6405,6 +6567,7 @@ export class TrainSchedulingService {
|
|||||||
const stops = this.mapScheduleStops(schedule);
|
const stops = this.mapScheduleStops(schedule);
|
||||||
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
|
||||||
const plan = schedule.plannedWagonYards ?? {};
|
const plan = schedule.plannedWagonYards ?? {};
|
||||||
|
const cutPlan = schedule.plannedWagonCutYards ?? {};
|
||||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
where: { trainId: builtTrain.id },
|
where: { trainId: builtTrain.id },
|
||||||
relations: { wagonType: true, currentYard: true },
|
relations: { wagonType: true, currentYard: true },
|
||||||
@@ -6432,6 +6595,7 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const rows = wagons.map((w) => {
|
const rows = wagons.map((w) => {
|
||||||
const plannedYardId = scheduleYardOf(plan, w);
|
const plannedYardId = scheduleYardOf(plan, w);
|
||||||
|
const cutYardId = cutPlan[w.id] ?? null;
|
||||||
const locked = lockedIds.has(w.id);
|
const locked = lockedIds.has(w.id);
|
||||||
return {
|
return {
|
||||||
id: w.id,
|
id: w.id,
|
||||||
@@ -6444,6 +6608,8 @@ export class TrainSchedulingService {
|
|||||||
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
|
||||||
plannedYardId,
|
plannedYardId,
|
||||||
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
|
||||||
|
cutYardId,
|
||||||
|
cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null,
|
||||||
aligned: plannedYardId === w.currentYardId,
|
aligned: plannedYardId === w.currentYardId,
|
||||||
locked,
|
locked,
|
||||||
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
|
||||||
@@ -6455,6 +6621,7 @@ export class TrainSchedulingService {
|
|||||||
pickup: pickupYardIds.has(s.yardId),
|
pickup: pickupYardIds.has(s.yardId),
|
||||||
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
|
||||||
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
|
||||||
|
cut: rows.filter((r) => r.cutYardId === s.yardId).length,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -6467,14 +6634,16 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-plan which yard this departure boards wagons from. Only DRAFT/SCHEDULED
|
* Re-plan which yard this departure boards wagons from (`yardId`) and/or
|
||||||
* schedules, only the train's own wagons, only pickup stops of the route,
|
* where it cuts them mid-route (`cutYardId`; null clears — the wagon rides
|
||||||
* never a wagon already carrying this schedule's cargo. Physical yards are
|
* to the destination). Only DRAFT/SCHEDULED schedules, only the train's own
|
||||||
* untouched — the train builder owns those.
|
* 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(
|
async updateScheduleWagonYards(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
moves: Array<{ wagonId: string; yardId: string }>,
|
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>,
|
||||||
) {
|
) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
@@ -6505,27 +6674,76 @@ export class TrainSchedulingService {
|
|||||||
.map((slot) => slot.physicalWagonId as string),
|
.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<string, number>();
|
||||||
|
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 plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
|
||||||
|
const cutPlan: Record<string, string> = { ...(schedule.plannedWagonCutYards ?? {}) };
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
const wagon = wagonById.get(move.wagonId);
|
const wagon = wagonById.get(move.wagonId);
|
||||||
if (!wagon) {
|
if (!wagon) {
|
||||||
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
|
throw new BadRequestException(`Wagon ${move.wagonId} is not coupled to train ${builtTrain.code}`);
|
||||||
}
|
}
|
||||||
if (!pickupYardIds.has(move.yardId)) {
|
if (move.yardId !== undefined) {
|
||||||
throw new BadRequestException(
|
if (!pickupYardIds.has(move.yardId)) {
|
||||||
`Yard ${move.yardId} is not a pickup stop of this schedule's route`,
|
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) {
|
if (move.cutYardId === null) {
|
||||||
throw new ConflictException(
|
delete cutPlan[wagon.id];
|
||||||
`Wagon ${wagon.wagonNumber} already carries cargo booked on this schedule and cannot change yard`,
|
} 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
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { plannedWagonYards: plan });
|
.update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan });
|
||||||
|
|
||||||
// ponytail: per-stop over-booking check counts bookings boarding at the
|
// ponytail: per-stop over-booking check counts bookings boarding at the
|
||||||
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
// stop against wagons planned there, ignoring leg sharing — a warning, not
|
||||||
@@ -7953,6 +8171,8 @@ export class TrainSchedulingService {
|
|||||||
weightTons: Number.POSITIVE_INFINITY,
|
weightTons: Number.POSITIVE_INFINITY,
|
||||||
lengthMeters: 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 ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
if (!sb.booking) continue;
|
if (!sb.booking) continue;
|
||||||
budget.subtract(
|
budget.subtract(
|
||||||
@@ -8280,8 +8500,14 @@ export class TrainSchedulingService {
|
|||||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||||
milestoneYards = milestones.map((m) => m.yardId);
|
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
|
const raw = milestoneYards.length >= 2
|
||||||
? milestoneYards
|
? orientStopsToSchedule(
|
||||||
|
milestoneYards,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
)
|
||||||
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
||||||
const unique: string[] = [];
|
const unique: string[] = [];
|
||||||
for (const yardId of raw) {
|
for (const yardId of raw) {
|
||||||
@@ -8973,27 +9199,44 @@ export class TrainSchedulingService {
|
|||||||
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||||||
(a, b) => a.sequenceNo - b.sequenceNo,
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||||
);
|
);
|
||||||
const raw = milestones.length >= 2
|
const milestoneStops = milestones.map((m) => ({
|
||||||
? milestones.map((m) => ({
|
yardId: m.yardId,
|
||||||
yardId: m.yardId,
|
label: m.yard?.label ?? m.yard?.code ?? 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
|
||||||
yardId: schedule.originStationId,
|
// backwards-traversed route is oriented to this schedule's endpoints.
|
||||||
label:
|
let raw: Array<{ yardId: string; label: string }>;
|
||||||
schedule.originStation?.label ??
|
if (milestoneStops.length >= 2) {
|
||||||
schedule.originStation?.code ??
|
const oriented = orientStopsToSchedule(
|
||||||
schedule.originStationId,
|
milestoneStops.map((s) => s.yardId),
|
||||||
},
|
schedule.originStationId,
|
||||||
{
|
schedule.destinationStationId,
|
||||||
yardId: schedule.destinationStationId,
|
);
|
||||||
label:
|
raw =
|
||||||
schedule.destinationStation?.label ??
|
oriented[0] === milestoneStops[0]?.yardId
|
||||||
schedule.destinationStation?.code ??
|
? milestoneStops
|
||||||
schedule.destinationStationId,
|
: [...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<string>();
|
const seen = new Set<string>();
|
||||||
return raw.filter((stop) => {
|
return raw.filter((stop) => {
|
||||||
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
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 containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||||
const placements = autoFillPlacements(units, slots);
|
const placements = this.autoFillOrFail(units, slots);
|
||||||
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
||||||
(m) => m.bookingId === bookingId,
|
(m) => m.bookingId === bookingId,
|
||||||
);
|
);
|
||||||
@@ -9222,7 +9465,7 @@ export class TrainSchedulingService {
|
|||||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||||
const placements = autoFillPlacements(units, slots);
|
const placements = this.autoFillOrFail(units, slots);
|
||||||
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
|
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
|
||||||
(m) => m.bookingId === governmentBookingId,
|
(m) => m.bookingId === governmentBookingId,
|
||||||
);
|
);
|
||||||
@@ -9360,7 +9603,7 @@ export class TrainSchedulingService {
|
|||||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||||
const placements = autoFillPlacements(units, slots);
|
const placements = this.autoFillOrFail(units, slots);
|
||||||
const missingNumbers = findMissingContainerNumberIssues(units, placements);
|
const missingNumbers = findMissingContainerNumberIssues(units, placements);
|
||||||
const missingByBooking = new Map<string, string>();
|
const missingByBooking = new Map<string, string>();
|
||||||
for (const m of missingNumbers) {
|
for (const m of missingNumbers) {
|
||||||
@@ -10062,7 +10305,20 @@ export class TrainSchedulingService {
|
|||||||
if (containerBookings.some((b) => b.id === booking.id)) {
|
if (containerBookings.some((b) => b.id === booking.id)) {
|
||||||
const units = expandBookingContainerUnits(containerBookings);
|
const units = expandBookingContainerUnits(containerBookings);
|
||||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
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(
|
const missing = findMissingContainerNumberIssues(units, placements).find(
|
||||||
(m) => m.bookingId === booking.id,
|
(m) => m.bookingId === booking.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
|
|
||||||
/** wagonId → yardId queued but not yet saved. */
|
/** wagonId → yardId queued but not yet saved. */
|
||||||
const [pending, setPending] = useState<Record<string, string>>({});
|
const [pending, setPending] = useState<Record<string, string>>({});
|
||||||
|
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
|
||||||
|
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
|
||||||
const [bulkType, setBulkType] = useState<string | null>(null);
|
const [bulkType, setBulkType] = useState<string | null>(null);
|
||||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||||
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
||||||
@@ -70,6 +72,32 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
"—";
|
"—";
|
||||||
|
|
||||||
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
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<string, string | null>,
|
||||||
|
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(
|
const perStop = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -77,8 +105,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
...s,
|
...s,
|
||||||
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
||||||
.length,
|
.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 typeOptions = useMemo(() => {
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
@@ -86,7 +117,7 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
return [...seen].map(([value, label]) => ({ value, label }));
|
return [...seen].map(([value, label]) => ({ value, label }));
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const pendingCount = Object.keys(pending).length;
|
const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size;
|
||||||
|
|
||||||
const queueBulk = () => {
|
const queueBulk = () => {
|
||||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||||
@@ -111,18 +142,29 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setPendingCut((prev) => {
|
||||||
|
let next = { ...prev };
|
||||||
|
for (const w of picked) next = clearInvalidCut(next, w, bulkTo);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!pendingCount) return;
|
if (!pendingCount) return;
|
||||||
try {
|
try {
|
||||||
|
const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])];
|
||||||
const result = await save.mutateAsync({
|
const result = await save.mutateAsync({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
payload: {
|
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({});
|
setPending({});
|
||||||
|
setPendingCut({});
|
||||||
toast({
|
toast({
|
||||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
||||||
@@ -153,8 +195,10 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
||||||
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
||||||
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
|
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
|
||||||
until every wagon stands at its planned yard.
|
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 ? (
|
{data.misaligned > 0 ? (
|
||||||
<Text component="span" c="orange" fw={600}>
|
<Text component="span" c="orange" fw={600}>
|
||||||
{" "}
|
{" "}
|
||||||
@@ -183,6 +227,16 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
||||||
Physical {s.physical}
|
Physical {s.physical}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{s.cut > 0 ? (
|
||||||
|
<Badge color="red" variant="light">
|
||||||
|
Cut {s.cut}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{!s.pickup ? (
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
@@ -229,13 +283,15 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
<Table.Th>Type</Table.Th>
|
<Table.Th>Type</Table.Th>
|
||||||
<Table.Th>Physical yard</Table.Th>
|
<Table.Th>Physical yard</Table.Th>
|
||||||
<Table.Th>Planned yard (this schedule)</Table.Th>
|
<Table.Th>Planned yard (this schedule)</Table.Th>
|
||||||
|
<Table.Th>Cut at (rides to)</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{data.wagons.map((w) => {
|
{data.wagons.map((w) => {
|
||||||
const planned = effectiveYard(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 (
|
return (
|
||||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||||
@@ -252,14 +308,17 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
data={yardOptions}
|
data={yardOptions}
|
||||||
value={planned}
|
value={planned}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
setPending((prev) => {
|
setPending((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (!v || v === w.plannedYardId) delete next[w.id];
|
if (!v || v === w.plannedYardId) delete next[w.id];
|
||||||
else next[w.id] = v;
|
else next[w.id] = v;
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
}
|
setPendingCut((prev) =>
|
||||||
|
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
|
||||||
|
);
|
||||||
|
}}
|
||||||
w={180}
|
w={180}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -273,6 +332,30 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{editable ? (
|
||||||
|
// Locked wagons stay editable here — the server enforces the
|
||||||
|
// cargo-destination floor and the toast explains a 409.
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
clearable
|
||||||
|
placeholder="Destination"
|
||||||
|
data={cutOptionsFor(planned)}
|
||||||
|
value={cut}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPendingCut((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if ((v ?? null) === w.cutYardId) delete next[w.id];
|
||||||
|
else next[w.id] = v ?? null;
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w={180}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{planned === w.physicalYardId ? (
|
{planned === w.physicalYardId ? (
|
||||||
<Badge color="teal" variant="light" size="sm">
|
<Badge color="teal" variant="light" size="sm">
|
||||||
@@ -295,7 +378,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{pendingCount} pending change(s)
|
{pendingCount} pending change(s)
|
||||||
</Text>
|
</Text>
|
||||||
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setPending({});
|
||||||
|
setPendingCut({});
|
||||||
|
}}
|
||||||
|
disabled={!pendingCount}
|
||||||
|
>
|
||||||
Discard
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -310,6 +310,9 @@ export interface ScheduleWagonYardRow {
|
|||||||
physicalYardLabel: string | null;
|
physicalYardLabel: string | null;
|
||||||
plannedYardId: string | null;
|
plannedYardId: string | null;
|
||||||
plannedYardLabel: string | null;
|
plannedYardLabel: string | null;
|
||||||
|
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
|
||||||
|
cutYardId: string | null;
|
||||||
|
cutYardLabel: string | null;
|
||||||
aligned: boolean;
|
aligned: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
lockReason: string | null;
|
lockReason: string | null;
|
||||||
@@ -322,6 +325,8 @@ export interface ScheduleWagonYardStop {
|
|||||||
pickup: boolean;
|
pickup: boolean;
|
||||||
planned: number;
|
planned: number;
|
||||||
physical: number;
|
physical: number;
|
||||||
|
/** Wagons this departure cuts (detaches and leaves) at this stop. */
|
||||||
|
cut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleWagonYards {
|
export interface ScheduleWagonYards {
|
||||||
@@ -334,7 +339,8 @@ export interface ScheduleWagonYards {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateScheduleWagonYardsPayload {
|
export interface UpdateScheduleWagonYardsPayload {
|
||||||
moves: Array<{ wagonId: string; yardId: string }>;
|
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
|
||||||
|
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||||
|
|||||||
Reference in New Issue
Block a user