Merge pull request #982 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-28 08:03:48 +03:00
committed by GitHub
90 changed files with 3132 additions and 3773 deletions

View File

@@ -1,5 +1,6 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
@@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => {
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
wagonStockForSchedule: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
@@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
// No physical stock configured → the wagon-type gate stands down and these
// specs keep testing the abstract capacity budget on its own.
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'YARD',
remainingByTypeId: new Map<string, number>(),
codesByTypeId: new Map<string, string>(),
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => {
};
await fn(manager);
}),
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
// Empty = unresolvable, so the physical-stock gate stands down and these
// specs keep exercising the abstract capacity budget alone.
query: jest.fn().mockResolvedValue([]),
};
notifier = {
@@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
return genericRepo;
}),
transaction: jest.fn(),
query: jest.fn().mockResolvedValue([]),
};
const service = new BookingBatchService(
dataSource as never,
@@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => {
});
});
});
/**
* The reported failure: a train advertising 20 free wagons where only 16 are of
* the type the booking can ride. Selecting all 20 took the customer's money for
* space that never existed and then stalled at allocation on wagon 17.
*/
describe('BookingBatchService — physical wagon-type gate', () => {
const NW5 = 'wagon-type-nw5';
const PW2 = 'wagon-type-pw2';
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
const internals = (svc: BookingBatchService) =>
svc as unknown as {
hasWagonStock: (
stock: WagonStockLedger,
ids: string[],
needed: number,
leg: { fromEdge: number; toEdge: number },
) => boolean;
maybeOfferPartial: (
booking: Booking,
isPair: boolean,
candidates: unknown[],
need: { wagons: number; weightTons: number; lengthMeters: number },
ids: string[],
) => Promise<boolean>;
tryPartialOffer: unknown;
isSplitEligible: unknown;
};
const service = () =>
new BookingBatchService(
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
const svc = internals(service());
const stock = mixedStock();
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
// A booking that may ride either type sees all 20.
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
});
it('stands down when the booking has no allowed wagon type configured', () => {
// Unresolvable configuration must not strand every booking that uses it —
// the abstract capacity budget still governs.
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
});
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
const svc = service();
const inner = internals(svc);
// Isolate the sizing decision: eligibility and offer creation are covered
// elsewhere, what matters here is the room handed to tryPartialOffer.
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
// 20 abstract slots free, weight and length wide open.
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const offered = await inner.maybeOfferPartial(
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
false,
[candidate],
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
[NW5],
);
expect(offered).toBe(true);
// 16, not the 20 free slots — the customer is billed for what can be loaded.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
});

View File

@@ -87,6 +87,7 @@ import {
OverageTolerance,
stopYardsFor,
} from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util';
export type { Capacity } from './corridor-capacity.util';
@@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const stock = await this.stockLedgerFor(schedule, budget);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
@@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed.
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
`stocked=${stocked}`,
);
if (!budget.fits(need, leg)) {
if (!budget.fits(need, leg) || !stocked) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
@@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit {
// Doesn't fit whole. A split-eligible import booking is offered the part
// that fits in the remaining room (top-up path splits the boundary
// booking, mirroring fillRouteDay); otherwise skip and try the next.
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
id: scheduleId,
budget,
armed,
};
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
const cand: {
id: string;
budget: CorridorBudget;
armed: boolean;
stock: WagonStockLedger;
} = { id: scheduleId, budget, armed, stock };
if (
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
) {
armed = cand.armed;
continue;
}
continue; // skip a unit that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
}
}
@@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
budget.subtract(need, leg);
// Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
// Live per-schedule corridor budget + arm/changed flags, in departure order.
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
// flags, in departure order.
const trains: Array<{
id: string;
budget: CorridorBudget;
stock: WagonStockLedger;
armed: boolean;
changed: boolean;
}> = [];
@@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false, changed: false });
const stock = await this.stockLedgerFor(schedule, budget);
trains.push({ id, budget, stock, armed: false, changed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
@@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit {
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
// leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
return (
leg != null &&
t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
);
});
// Per-unit trace: chosen train + each train's remaining room on this leg.
@@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit {
// already consumed most of the room). Consolidated pairs / government /
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
const offered = await this.maybeOfferPartial(
booking,
isPair,
trains,
need,
wagonTypeIds,
);
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
@@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5.
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
target.changed = true;
reservedThisPass += 1;
} catch (err) {
@@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit {
private async maybeOfferPartial(
booking: Booking,
isPair: boolean,
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
candidates: Array<{
id: string;
budget: CorridorBudget;
armed: boolean;
stock?: WagonStockLedger;
}>,
need: Capacity,
wagonTypeIds: string[] = [],
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
if (!leg) return null;
const room = c.budget.remainingFor(leg);
// The offer may never exceed the wagons that physically exist in a type
// this booking can ride. This is what turns "20 free wagons, only 16 of
// them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17.
const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg)
: undefined;
const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } };
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2047,6 +2099,7 @@ export class BookingBatchService implements OnModuleInit {
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
target.c.armed = true;
return true;
}
@@ -3468,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit {
return dims.length ? dims : [fallback];
}
/**
* Physical wagon-type stock for one schedule, on the same corridor edges its
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
* batch counts exactly the wagons the allocator will later plan against.
*/
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
}
/**
* Whether the train holds enough PHYSICAL wagons of the types this booking may
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
* abstract budget still governs, and a mis-configured cargo type must not
* silently strand every booking that uses it.
*/
private hasWagonStock(
stock: WagonStockLedger,
wagonTypeIds: string[],
wagonsNeeded: number,
leg: CorridorLeg,
): boolean {
if (!wagonTypeIds.length) return true;
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
}
private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
expiresAt: number;
} | null = null;
/**
* Wagon-type ids each cargo / container type may ride, read straight from the
* join tables.
*
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
* a hot path. So the pool's booking entities carry the type FK but not the
* allowed list, and resolving it per booking through the relation would come
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
* give the same answer without touching the pool query.
*/
private async loadAllowedWagonTypeIds(): Promise<{
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
}> {
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
return this.allowedWagonTypeCache;
}
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
// scheduling service — the allocator will not plan against them either.
const [cargoRows, containerRows]: [
Array<{ typeId: string; wagonTypeId: string }>,
Array<{ typeId: string; wagonTypeId: string }>,
] = await Promise.all([
this.dataSource.query(
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
this.dataSource.query(
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
]);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, string[]>();
for (const row of rows) {
const list = map.get(row.typeId) ?? [];
list.push(row.wagonTypeId);
map.set(row.typeId, list);
}
return map;
};
const value = {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
};
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
* caller must then skip the physical-stock gate rather than block the booking
* on missing configuration.
*/
private allowedWagonTypeIdsFor(
booking: Booking,
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): string[] {
if (booking.freightType === "BULK") {
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
}
const ids = new Set<string>();
for (const line of booking.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
ids.add(id);
}
}
return [...ids];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.

View File

@@ -5,6 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -48,6 +49,7 @@ export class BookingJourneyService {
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -145,6 +147,12 @@ export class BookingJourneyService {
});
});
// Intercity ends here — a ONE_TIME contract closes on its shipment being
// delivered (import/export emit this from booking-transition.complete).
if (nextStatus === 'COMPLETED') {
this.events.emit('booking.completed', { bookingId });
}
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -303,6 +311,12 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
}
return rows.map((r) => r.id);
}

View File

@@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto {
type: [String],
format: 'uuid',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds?: string[];

View File

@@ -5,7 +5,7 @@ import {
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
@@ -197,42 +197,75 @@ describe('train-capacity.util', () => {
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('takes the weakest locomotive across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});

View File

@@ -256,27 +256,40 @@ function round3(value: number): number {
}
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
* Effective limits for a train set, per axis:
*
* - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull
* 3500T. Only CONFIGURED pull weights are summed; a set with none configured
* reports Infinity (no opinion), exactly as before.
* - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its
* own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0).
* - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not
* a haulage one: coupling a second locomotive does not lengthen the track, so
* the most restrictive locomotive still governs (and its tolerance with it).
*
* Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
export function combinedLocomotiveLimits(
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
const configuredPulls = locomotives
.map((l) => num(l.maxPullWeightTons))
.filter((v) => v > 0);
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxPullWeightTons: configuredPulls.length
? round3(configuredPulls.reduce((sum, v) => sum + v, 0))
: Infinity,
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
),
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
// tolerance set has no opinion, it does not zero out the others.
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)),
// Paired with the length axis, so it stays the weakest CONFIGURED value — a
// locomotive with no tolerance set has no opinion, it does not zero the others.
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
};
}
@@ -286,10 +299,16 @@ function minConfigured(values: Array<number | null | undefined>): number {
return configured.length ? Math.min(...configured) : 0;
}
function sumConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
* Effective limits for a whole train set: {@link combinedLocomotiveLimits} over
* its linked locomotives, falling back to the legacy single `locomotive` column
* for sets created before multi-loco support. Null when the set has no
* locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
@@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits(
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
return combinedLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */

View File

@@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
@@ -1300,9 +1300,9 @@ export class TrainSchedulingService {
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 2) {
if (locomotiveIds.length < 1) {
throw new BadRequestException(
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
@@ -1323,8 +1323,8 @@ export class TrainSchedulingService {
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
}
@@ -1382,7 +1382,7 @@ export class TrainSchedulingService {
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
@@ -1572,7 +1572,7 @@ export class TrainSchedulingService {
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
@@ -3971,36 +3971,12 @@ export class TrainSchedulingService {
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
stock = await this.builtTrainStock(builtTrainId);
} else {
// Dynamic consist: a slot's physical wagon may ride from the train's origin
// OR already sit at the booking's own boarding yard and attach there — so
// the usable fleet is the union across the origin and every boarding yard.
const boardYardIds = [
...new Set(
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) =>
this.countFleetAvailability(yardId, targetScheduleId),
),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const stock: WagonStock = await this.wagonStockForSchedule(
targetScheduleId,
originYardId,
bookings.map((b) => b.originYardId),
builtTrainId,
);
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
@@ -4129,7 +4105,7 @@ export class TrainSchedulingService {
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
if (offYard) {
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
@@ -4799,6 +4775,51 @@ export class TrainSchedulingService {
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
/**
* The physical wagons a schedule can actually plan against, by wagon type.
*
* A schedule built from a Train Builder train plans against ONLY that train's
* own consist. A legacy/dynamic-consist schedule plans against the boarding
* yards' loose pool: a slot's wagon may ride from the train's origin OR
* already sit at the booking's own boarding yard and attach there, so the
* usable fleet is the union across the origin and every boarding yard.
*
* Public because batch fill needs the SAME stock the allocator will later
* validate against — selecting a booking the allocator cannot place is how
* customers ended up paying for wagons that were never there.
*/
async wagonStockForSchedule(
scheduleId: string | undefined,
originYardId: string,
boardingYardIds: Array<string | null | undefined> = [],
preloadedBuiltTrainId?: string | null,
): Promise<WagonStock> {
const builtTrainId =
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
@@ -5347,7 +5368,7 @@ export class TrainSchedulingService {
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with fewer than two locomotives are omitted (never schedulable).
* Trains with no locomotive at all are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
@@ -5385,7 +5406,7 @@ export class TrainSchedulingService {
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 2)
.filter((train) => (train.locomotives ?? []).length >= 1)
.map((train) => {
const wagons = train.wagons ?? [];
return {
@@ -5417,7 +5438,15 @@ export class TrainSchedulingService {
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: roundTons(Number(train.capacityTons)),
// Live from the coupled set — `capacity_tons` still holds the old
// single-locomotive figure on trains built before pull weight summed.
maxPullWeightTons: roundTons(
combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
)?.maxPullWeightTons ?? Number(train.capacityTons),
),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
@@ -5467,7 +5496,7 @@ export class TrainSchedulingService {
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
@@ -5590,7 +5619,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);

View File

@@ -0,0 +1,70 @@
import { WagonStockLedger } from './wagon-stock-ledger.util';
const WHOLE = { fromEdge: 0, toEdge: 1 };
describe('WagonStockLedger', () => {
it('reports the wagons of a booking\'s OWN types, not the train total', () => {
// The reported case: 20 free wagons on the train, but only 16 of them NW5.
const ledger = new WagonStockLedger(
new Map([
['nw5', 16],
['pw2', 4],
]),
1,
);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16);
expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4);
// A cargo type mapped to both may ride either, so they add up.
expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20);
// Duplicates must not double-count.
expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16);
// An unconfigured type has no stock.
expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0);
});
it('consumes what it can and reports the shortfall', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1);
// A 20-wagon booking can only take 16 — the caller splits on that number.
expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0);
});
it('drains the deepest stock first across candidate types', () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 10],
['nw7', 3],
]),
1,
);
expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12);
// 10 from NW5 then 2 from NW7 — one NW7 left.
expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
});
it('frees stock past an alight yard — disjoint legs never compete', () => {
// Three stops (A→B→C) = two edges. An intercity booking riding A→B must
// not consume the wagon on B→C.
const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2);
const firstLeg = { fromEdge: 0, toEdge: 1 };
const secondLeg = { fromEdge: 1, toEdge: 2 };
ledger.consume(['nw5'], 5, firstLeg);
expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0);
expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5);
// A whole-route booking sees the busiest edge it crosses, so it is blocked.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0);
});
it('counts the busiest edge within a leg, not the sum of edges', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3);
ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 });
ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 });
// Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4);
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});

View File

@@ -0,0 +1,86 @@
import type { CorridorLeg } from './corridor-capacity.util';
/**
* Physical wagon-type stock for one train, consumed per corridor edge.
*
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
* yard" — money taken for space that never existed.
*
* This ledger is the missing axis: how many wagons of the types a booking may
* actually ride are free. Batch fill consults it alongside the budget, so a
* booking is admitted whole only when both agree, and is otherwise offered a
* split sized to the wagons that genuinely exist.
*
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
* an alight yard is available again downstream, so an intercity ride-along on
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
private readonly usedPerEdge = new Map<string, number[]>();
constructor(
private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number,
) {}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = this.usedPerEdge.get(wagonTypeId);
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
busiest = Math.max(busiest, row[edge] ?? 0);
}
return Math.max(0, total - busiest);
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.
*/
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
let total = 0;
for (const id of new Set(wagonTypeIds)) {
total += this.availableForType(id, leg);
}
return total;
}
/**
* Take `wagons` from the candidate types, deepest stock first so the consist
* drains evenly (same tie-break as the wagon planner). Returns how many were
* actually taken — less than asked when the stock is short.
*/
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
let outstanding = Math.max(0, Math.floor(wagons));
const candidates = [...new Set(wagonTypeIds)];
let taken = 0;
while (outstanding > 0) {
const deepest = candidates
.map((id) => ({ id, free: this.availableForType(id, leg) }))
.filter((c) => c.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;
}
outstanding -= take;
taken += take;
}
return taken;
}
}