[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries

This commit is contained in:
Marshal
2026-08-20 15:45:42 +00:00
parent 8d7551bb8e
commit 9e1d5ee9f2
58 changed files with 3324 additions and 810 deletions

View File

@@ -48,6 +48,7 @@ import {
} from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
import { UpdateScheduleWagonYardsDto } from "../dto/update-schedule-wagon-yards.dto";
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
@@ -201,6 +202,29 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getScheduleConsist(id);
}
@Get("schedules/:id/wagon-yards")
@TrainSchedulingView()
@ApiOperation({
summary:
"Schedule wagon yard plan: where THIS departure boards each consist wagon vs where it physically stands, per-stop totals, locked wagons",
})
getScheduleWagonYards(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleWagonYards(id);
}
@Patch("schedules/:id/wagon-yards")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Re-plan the yard this departure boards wagons from (schedule-only; physical yards untouched, dispatch requires alignment)",
})
updateScheduleWagonYards(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWagonYardsDto,
) {
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsUUID, 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.' })
@IsUUID()
yardId!: string;
}
export class UpdateScheduleWagonYardsDto {
@ApiProperty({
type: [ScheduleWagonYardMoveDto],
description:
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
})
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => ScheduleWagonYardMoveDto)
moves!: ScheduleWagonYardMoveDto[];
}

View File

@@ -138,6 +138,12 @@ import {
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import {
defaultPlannedWagonYards,
misalignedWagons,
type PlannedWagonYards,
scheduleYardOf,
} from '../utils/planned-wagon-yards.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
@@ -1557,6 +1563,7 @@ export class TrainSchedulingService {
// and yard follow the schedule.
let builtTrain: Train | null = null;
let locomotiveIds: string[];
let plannedWagonYards: PlannedWagonYards | null = null;
if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId },
@@ -1589,7 +1596,7 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
await this.assertRouteCoversWagonYards(builtTrain, route);
plannedWagonYards = await this.defaultPlannedWagonYardsFor(builtTrain, route, scheduleWarnings);
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
@@ -1844,6 +1851,7 @@ export class TrainSchedulingService {
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
plannedWagonYards,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields,
@@ -2742,6 +2750,9 @@ export class TrainSchedulingService {
);
}
}
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -5288,15 +5299,30 @@ export class TrainSchedulingService {
return rows[0]?.train_id ?? null;
}
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
private async plannedWagonYardsOf(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<PlannedWagonYards> {
if (!scheduleId) return {};
const runner = manager ?? this.dataSource;
const rows: { planned_wagon_yards: PlannedWagonYards | null }[] = await runner.query(
`SELECT planned_wagon_yards FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
return rows[0]?.planned_wagon_yards ?? {};
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
this.plannedWagonYardsOf(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
@@ -5304,11 +5330,14 @@ export class TrainSchedulingService {
// A built consist spread across several yards can only offer, at each yard,
// the wagons standing there. A single-yard consist keeps the original
// behaviour: the whole train counts wherever it currently sits.
// Yards are the SCHEDULE's plan (falling back to the physical yard), so a
// departure sold from Dire counts its Dire wagons even while they still
// stand in Mojo — dispatch is what demands the two agree.
const consistYards = builtTrainId
? new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plan, w))
.map((w) => scheduleYardOf(plan, w) as string),
)
: new Set<string>();
const consistIsSplit = consistYards.size > 1;
@@ -5320,7 +5349,7 @@ export class TrainSchedulingService {
// counted at the yard each wagon actually stands in.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
if (consistIsSplit && scheduleYardOf(plan, wagon) !== originYardId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
@@ -5491,6 +5520,7 @@ export class TrainSchedulingService {
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const plannedYards = pinSchedule?.plannedWagonYards ?? {};
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
@@ -5500,6 +5530,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
plannedYards,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -5521,6 +5552,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
plannedYards,
);
if (!physical) continue;
@@ -5570,6 +5602,7 @@ export class TrainSchedulingService {
builtTrainId,
pinnedToScheduleIds,
stops,
targetSchedule?.plannedWagonYards ?? {},
);
}
@@ -5602,6 +5635,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
plannedYards: PlannedWagonYards = {},
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
@@ -5620,6 +5654,8 @@ export class TrainSchedulingService {
span,
builtTrainId,
pinnedToScheduleIds,
false,
plannedYards,
);
if (!physical) {
violations.push(
@@ -5650,6 +5686,7 @@ export class TrainSchedulingService {
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
plannedYards: PlannedWagonYards = {},
): Wagon | undefined {
// 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.
@@ -5682,8 +5719,8 @@ export class TrainSchedulingService {
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const consistYards = new Set(
wagons
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
.map((w) => w.currentYardId as string),
.filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w))
.map((w) => scheduleYardOf(plannedYards, w) as string),
);
// Split consist: a slot boarding at a given yard must take a wagon that
// physically stands there — the train cannot load a Mojo wagon at Dire.
@@ -5696,7 +5733,7 @@ export class TrainSchedulingService {
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id) &&
(!requiredYardId || w.currentYardId === requiredYardId),
(!requiredYardId || scheduleYardOf(plannedYards, w) === requiredYardId),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
@@ -5836,7 +5873,7 @@ export class TrainSchedulingService {
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
if (builtTrainId) return this.builtTrainStock(builtTrainId, scheduleId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
@@ -5858,11 +5895,17 @@ export class TrainSchedulingService {
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
});
private async builtTrainStock(
builtTrainId: string,
scheduleId?: string,
): Promise<WagonStock> {
const [wagons, plan] = await Promise.all([
this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
}),
this.plannedWagonYardsOf(scheduleId),
]);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
const byYardId = new Map<string, Map<string, number>>();
@@ -5872,10 +5915,12 @@ export class TrainSchedulingService {
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
if (wagon.currentYardId) {
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
// The schedule's own yard plan, not the physical yard — see plannedWagonYards.
const yardId = scheduleYardOf(plan, wagon);
if (yardId) {
const perType = byYardId.get(yardId) ?? new Map<string, number>();
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
byYardId.set(wagon.currentYardId, perType);
byYardId.set(yardId, perType);
}
}
// Single-yard consist (the overwhelming majority): the whole train is
@@ -6215,17 +6260,22 @@ export class TrainSchedulingService {
}
/**
* A built train's wagons may stand in several yards. The route must pass
* through every one of them as origin or an intermediate stop — never only
* as the final destination (the train has to pick the wagons up en route).
* Default yard plan for a schedule created from a built train: every wagon
* keeps the yard it physically stands in when that yard is a pickup stop of
* the route (origin or intermediate — never only the destination, the train
* has to collect it en route); the rest are planned at the origin and
* reported as a warning so staff can redistribute in the schedule-yards tab.
*/
private async assertRouteCoversWagonYards(train: Train, route: Route) {
private async defaultPlannedWagonYardsFor(
train: Train,
route: Route,
warnings: string[],
): Promise<PlannedWagonYards | null> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: train.id },
select: { id: true, currentYardId: true },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
if (!wagonYards.length) return;
if (!wagons.length) return null;
const milestones = await this.dataSource
.getRepository(RouteMilestone)
@@ -6236,23 +6286,203 @@ export class TrainSchedulingService {
// Every stop except the last one is a pickup point.
const pickupYards = new Set(stops.slice(0, -1));
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
if (!uncovered.length) return;
const { plan, rehomed } = defaultPlannedWagonYards(wagons, pickupYards, route.originYardId);
if (rehomed.length) {
const labels = await this.yardLabelMap([
...new Set(rehomed.map((w) => w.currentYardId).filter((y): y is string => !!y)),
]);
const origin = labels.get(route.originYardId) ?? route.originYard?.label ?? 'the origin';
const where = [...new Set(rehomed.map((w) => (w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard')))].join(', ');
warnings.push(
`${rehomed.length} wagon(s) of train ${train.code} stand off this route (${where}) and were planned at ${origin}; ` +
'they must be moved there before dispatch — adjust in the schedule yards tab if they should board elsewhere',
);
}
return plan;
}
const labels = await this.yardLabelMap(uncovered);
const destination = stops[stops.length - 1];
const detail = uncovered
.map((y) =>
y === destination
? `${labels.get(y) ?? y} (only as the destination)`
: `${labels.get(y) ?? y} (not on route)`,
)
/** Dispatch gate: every planned wagon must physically stand at its planned yard. */
private async assertPlannedYardsAligned(schedule: TrainSchedule) {
const builtTrainId = schedule.trainSet?.trainId;
if (!builtTrainId) return;
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const off = misalignedWagons(schedule.plannedWagonYards, wagons);
if (!off.length) return;
const plan = schedule.plannedWagonYards ?? {};
const labels = await this.yardLabelMap([
...new Set(off.flatMap((w) => [plan[w.id], w.currentYardId]).filter((y): y is string => !!y)),
]);
const detail = off
.slice(0, 5)
.map((w) => `${w.wagonNumber} (planned ${labels.get(plan[w.id]) ?? plan[w.id]}, at ${w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : 'no yard'})`)
.join(', ');
throw new BadRequestException(
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
throw new ConflictException(
`Cannot dispatch: ${off.length} wagon(s) are not at the yard this schedule planned them for — ${detail}` +
(off.length > 5 ? ', …' : '') +
'. Move them in the train builder or re-plan them in the schedule yards tab.',
);
}
/**
* Schedule-yards tab: where THIS departure boards each consist wagon vs
* where it physically stands, per stop totals, and which wagons are locked
* (already carrying this schedule's cargo).
*/
async getScheduleWagonYards(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const plan = schedule.plannedWagonYards ?? {};
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true, currentYard: true },
order: { sequenceNumber: 'ASC' },
});
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const offRouteYardIds = [
...new Set(
wagons
.flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId])
.filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)),
),
];
const labels = new Map([
...stops.map((s) => [s.yardId, s.label] as const),
...(await this.yardLabelMap(offRouteYardIds)),
]);
const editable =
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled;
const rows = wagons.map((w) => {
const plannedYardId = scheduleYardOf(plan, w);
const locked = lockedIds.has(w.id);
return {
id: w.id,
wagonNumber: w.wagonNumber,
sequenceNumber: w.sequenceNumber,
wagonType: w.wagonType
? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name }
: { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId },
physicalYardId: w.currentYardId,
physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null,
plannedYardId,
plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null,
aligned: plannedYardId === w.currentYardId,
locked,
lockReason: locked ? 'Carries cargo booked on this schedule' : null,
};
});
const perStop = stops.map((s) => ({
yardId: s.yardId,
label: s.label,
pickup: pickupYardIds.has(s.yardId),
planned: rows.filter((r) => r.plannedYardId === s.yardId).length,
physical: rows.filter((r) => r.physicalYardId === s.yardId).length,
}));
return {
scheduleId,
train: { id: builtTrain.id, code: builtTrain.code },
editable,
stops: perStop,
wagons: rows,
misaligned: rows.filter((r) => !r.aligned).length,
};
}
/**
* 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.
*/
async updateScheduleWagonYards(
scheduleId: string,
moves: Array<{ wagonId: string; yardId: string }>,
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — it has no wagon yard plan',
);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new ConflictException(
`Wagon yards can only be re-planned before departure (schedule is ${schedule.status})`,
);
}
const stops = this.mapScheduleStops(schedule);
const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId));
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
select: { id: true, currentYardId: true, wagonNumber: true },
});
const wagonById = new Map(wagons.map((w) => [w.id, w]));
const lockedIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) };
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 (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;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { plannedWagonYards: plan });
// ponytail: per-stop over-booking check counts bookings boarding at the
// stop against wagons planned there, ignoring leg sharing — a warning, not
// a gate; switch to CorridorBudget per stop if staff need exact numbers.
const warnings: string[] = [];
for (const stop of stops.slice(0, -1)) {
const planned = wagons.filter((w) => scheduleYardOf(plan, w) === stop.yardId).length;
const booked = (schedule.scheduleBookings ?? [])
.filter((sb) => sb.booking?.originYardId === stop.yardId)
.reduce((sum, sb) => sum + (sb.booking ? this.effectiveWagonsRequired(sb.booking) : 0), 0);
if (booked > planned) {
warnings.push(
`${stop.label}: bookings boarding here need ${booked} wagon(s) but only ${planned} are planned at this yard`,
);
}
}
return { ...(await this.getScheduleWagonYards(scheduleId)), warnings };
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },

View File

@@ -0,0 +1,30 @@
import {
defaultPlannedWagonYards,
misalignedWagons,
scheduleYardOf,
} from './planned-wagon-yards.util';
const w = (id: string, currentYardId: string | null) => ({ id, currentYardId });
describe('planned-wagon-yards.util', () => {
it('scheduleYardOf prefers the plan and falls back to the physical yard', () => {
expect(scheduleYardOf({ w1: 'B' }, w('w1', 'C'))).toBe('B');
expect(scheduleYardOf({ w1: 'B' }, w('w2', 'C'))).toBe('C');
expect(scheduleYardOf(null, w('w2', null))).toBeNull();
});
it('defaultPlannedWagonYards snapshots on-route yards and rehomes the rest to origin', () => {
const { plan, rehomed } = defaultPlannedWagonYards(
[w('a', 'A'), w('b', 'B'), w('x', 'X'), w('n', null)],
new Set(['A', 'B', 'C']),
'A',
);
expect(plan).toEqual({ a: 'A', b: 'B', x: 'A', n: 'A' });
expect(rehomed.map((r) => r.id)).toEqual(['x', 'n']);
});
it('misalignedWagons lists only planned wagons standing elsewhere', () => {
const out = misalignedWagons({ a: 'A', b: 'B' }, [w('a', 'A'), w('b', 'C'), w('z', 'Z')]);
expect(out.map((r) => r.id)).toEqual(['b']);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Per-schedule wagon yard plan: `{ wagonId: yardId }` — where THIS departure
* boards each consist wagon, independent of where the steel physically stands
* (`wagons.current_yard_id`, one fact shared by every schedule of the train).
*/
export type PlannedWagonYards = Record<string, string>;
type YardedWagon = { id: string; currentYardId: string | null };
/** Yard a schedule boards a wagon from: its own plan first, the physical yard otherwise. */
export function scheduleYardOf(
plan: PlannedWagonYards | null | undefined,
wagon: YardedWagon,
): string | null {
return plan?.[wagon.id] ?? wagon.currentYardId;
}
/**
* Default plan when a schedule is created from a built train: snapshot every
* wagon's physical yard (so later physical moves never shift this departure's
* capacity); a wagon standing off the route's pickup stops — or nowhere — is
* planned at the origin instead, and reported back so staff can redistribute.
*/
export function defaultPlannedWagonYards(
wagons: readonly YardedWagon[],
pickupYardIds: ReadonlySet<string>,
originYardId: string,
): { plan: PlannedWagonYards; rehomed: YardedWagon[] } {
const plan: PlannedWagonYards = {};
const rehomed: YardedWagon[] = [];
for (const wagon of wagons) {
if (wagon.currentYardId && pickupYardIds.has(wagon.currentYardId)) {
plan[wagon.id] = wagon.currentYardId;
} else {
plan[wagon.id] = originYardId;
rehomed.push(wagon);
}
}
return { plan, rehomed };
}
/** Wagons whose planned yard disagrees with where they physically stand. */
export function misalignedWagons<T extends YardedWagon>(
plan: PlannedWagonYards | null | undefined,
wagons: readonly T[],
): T[] {
return wagons.filter((w) => {
const planned = plan?.[w.id];
return planned != null && planned !== w.currentYardId;
});
}