mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge pull request #982 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user