feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -1218,12 +1218,7 @@ export class BookingBatchService implements OnModuleInit {
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
@@ -4782,18 +4777,36 @@ export class BookingBatchService implements OnModuleInit {
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
excludeBookingIds?: string[],
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
// Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard.
const [wagonDims, allowed] = await Promise.all([
this.loadWagonDims(),
this.loadAllowedWagonTypeIds(),
]);
const anyType = [...stock.remainingByTypeId.keys()];
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
ledger.consume(
typeIds.length ? typeIds : anyType,
this.wagonsFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return ledger;
}
/**
@@ -4956,6 +4969,29 @@ export class BookingBatchService implements OnModuleInit {
// wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/**
* Every booking already holding capacity on the schedule: allocated (linked),
* live-reserved (unexpired pay window or paid), and pending export requests
* that named this train. The ONE list both the abstract corridor budget and
* the per-yard wagon-type ledger must debit — when only the budget saw them,
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
* still advertised "15 free" there, because the whole-train budget had room
* left on that edge (from the other yard's wagons) and the ledger was born
* full.
*/
private async committedBookings(
schedule: TrainSchedule,
excludeBookingIds?: string[],
): Promise<Booking[]> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
@@ -4988,13 +5024,14 @@ export class BookingBatchService implements OnModuleInit {
relations: ['bookingContainers'],
})
).filter((b) => !excludeBookingIds?.includes(b.id));
for (const b of [...allocated, ...reserved, ...pendingHolds]) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
// A booking can sit in more than one set (allocated AND still reserved);
// it holds its wagons once.
const seen = new Set<string>();
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
seen.add(b.id);
return true;
});
}
/**

View File

@@ -0,0 +1,115 @@
import { BookingBatchService } from './booking-batch.service';
import { CorridorBudget } from './corridor-capacity.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Regression: a train with 15 wagons planned at Mojo and a 15-wagon booking
* already committed from Mojo advertised "15 free at Mojo" — the whole-train
* corridor budget still had room on that edge (GMP's wagons), and the per-yard
* stock ledger was born full. The ledger must be debited by the SAME committed
* bookings the budget subtracts.
*/
describe('BookingBatchService — per-yard stock ledger debits committed bookings', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const booking = (id: string, originYardId: string, wagonsRequired: number) =>
({
id,
freightType: 'BULK',
cargoTypeId: 'ct-coffee',
wagonsRequired,
originYardId,
destinationYardId: DCT,
cargoTotalWeightVgm: 1,
bookingContainers: [],
}) as unknown as Booking;
const schedule = {
id: 'S-35',
routeId: 'route-1',
originStationId: GMP,
destinationStationId: DCT,
scheduleBookings: [{ booking: booking('BK-118', MOJO, 15) }, { booking: booking('BK-120', GMP, 1) }],
} as never;
const makeService = (pendingHolds: Booking[] = []) => {
const milestoneRepo = {
find: jest.fn().mockResolvedValue([
{ yardId: GMP, sequenceNo: 1 },
{ yardId: MOJO, sequenceNo: 2 },
{ yardId: DCT, sequenceNo: 3 },
]),
};
const emptyRepo = { find: jest.fn().mockResolvedValue([]) };
// Booking.find is only used for OPERATION_REQUEST_PENDING export holds.
const bookingRepo = { find: jest.fn().mockResolvedValue(pendingHolds) };
const dataSource = {
getRepository: jest.fn((entity: unknown) =>
entity === RouteMilestone ? milestoneRepo : entity === Booking ? bookingRepo : emptyRepo,
),
query: jest.fn(async (sql: string) =>
sql.includes('cargo_type_wagon_types') ? [{ typeId: 'ct-coffee', wagonTypeId: 'nw5' }] : [],
),
};
const trainSchedulingService = {
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'TRAIN',
remainingByTypeId: new Map([['nw5', 46]]),
codesByTypeId: new Map([['nw5', 'NW5']]),
byYardId: new Map([
[GMP, new Map([['nw5', 31]])],
[MOJO, new Map([['nw5', 15]])],
]),
}),
};
return new BookingBatchService(
dataSource as never,
{ findReservedForSchedule: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{} as never,
{} as never,
{} as never,
trainSchedulingService as never,
{} as never,
{} as never,
{} as never,
);
};
const budget = () =>
new CorridorBudget([GMP, MOJO, DCT], {
wagons: 46,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
it('shows 0 free at Mojo once its 15 planned wagons are booked, while GMP keeps its own', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(0);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(30);
});
it('a pending export request already holds its wagons at its yard (before staff accept)', async () => {
const service = makeService([booking('BK-REQ', MOJO, 10)]) as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const emptySchedule = { ...(schedule as object), scheduleBookings: [] } as never;
const b = budget();
const ledger = await service.stockLedgerFor(emptySchedule, b);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(5);
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(31);
});
it('excludes the booking being evaluated so a request never blocks its own accept', async () => {
const service = makeService() as unknown as {
stockLedgerFor: BookingBatchService['stockLedgerFor'];
};
const b = budget();
const ledger = await service.stockLedgerFor(schedule, b, ['BK-118']);
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(15);
});
});

View File

@@ -28,20 +28,30 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
export function autoFillPlacements(
units: ContainerUnitForPlacement[],
containerSlots: number[],
/**
* TEU already taken per slot sequenceNo by placements the caller supplied.
* Without it a partial auto-fill restarted at wagon #1 and stacked a second
* 40ft onto a wagon another booking's placement had already filled.
*/
occupiedTeuBySlot: ReadonlyMap<number, number> = new Map(),
): ContainerPlacementInput[] {
if (!units.length || !containerSlots.length) return [];
const placements: ContainerPlacementInput[] = [];
const MAX_TEU_PER_WAGON = 2;
let currentSlotIndex = 0;
let teuInCurrentSlot = 0;
let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0;
for (const unit of units) {
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
while (
teuInCurrentSlot > 0 &&
teuInCurrentSlot + teu > MAX_TEU_PER_WAGON &&
currentSlotIndex < containerSlots.length - 1
) {
currentSlotIndex += 1;
teuInCurrentSlot = 0;
teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0;
}
const sequenceNo =
@@ -62,6 +72,25 @@ export function autoFillPlacements(
return placements;
}
/** TEU per slot sequenceNo consumed by the given placements. */
export function occupiedTeuBySlot(
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
units: ContainerUnitForPlacement[],
): Map<number, number> {
const teuOfUnit = new Map(
units.map((u) => [
`${u.bookingContainerId}:${u.unitIndex}`,
u.teuSlots ?? (u.sizeFt && u.sizeFt >= 40 ? 2 : 1),
]),
);
const out = new Map<number, number>();
for (const p of placements) {
const teu = teuOfUnit.get(`${p.bookingContainerId}:${p.unitIndex}`) ?? 1;
out.set(p.sequenceNo, (out.get(p.sequenceNo) ?? 0) + teu);
}
return out;
}
export function findMissingContainerNumberIssues(
units: ContainerUnitForPlacement[],
placements: ContainerPlacementInput[],

View File

@@ -56,6 +56,14 @@ export class AssignBookingsDto {
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({
description:
'Linked bookings the plan cannot seat stay linked as WAITING_FOR_WAGON instead of failing the whole allocation (auto-allocation mode). Requested bookingIds still fail loudly.',
})
@IsOptional()
@IsBoolean()
keepDeferredLinked?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()

View File

@@ -199,6 +199,7 @@ import {
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
occupiedTeuBySlot,
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -1996,7 +1997,11 @@ export class TrainSchedulingService {
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const generated = autoFillPlacements(
unplacedUnits,
slots,
occupiedTeuBySlot(containerPlacements ?? [], units),
);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
@@ -2053,16 +2058,23 @@ export class TrainSchedulingService {
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
const dropped = allBookingIds.filter((id) => !plannedIds.has(id));
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const detailOf = (id: string) =>
reasonById.get(id) ?? `${id}: does not fit the train's wagon stock or capacity`;
// Auto-allocation (keepDeferredLinked): an ALREADY-LINKED booking the plan
// cannot seat — e.g. it boards at a yard whose planned wagons are all taken
// — stays linked and is parked WAITING_FOR_WAGON for staff, instead of
// aborting the whole rebuild and leaving every OTHER paid booking without a
// wagon too. Explicitly requested ids still fail loudly.
const keptDeferredIds = dto.keepDeferredLinked
? dropped.filter((id) => !dto.bookingIds.includes(id))
: [];
const droppedRequested = dropped.filter((id) => !keptDeferredIds.includes(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const details = droppedRequested.map(
(id) =>
reasonById.get(id) ??
`${id}: does not fit the train's wagon stock or capacity`,
);
const details = droppedRequested.map(detailOf);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
@@ -2072,6 +2084,9 @@ export class TrainSchedulingService {
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
for (const id of keptDeferredIds) {
warnings.push(`${detailOf(id)} — kept on the schedule, waiting for a wagon`);
}
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
@@ -2179,10 +2194,10 @@ export class TrainSchedulingService {
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
const scheduleBookingRecords = [
...bookings.map((booking) => booking.id),
...keptDeferredIds,
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
@@ -2208,6 +2223,13 @@ export class TrainSchedulingService {
manager,
);
}
for (const bookingId of keptDeferredIds) {
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus: SchedulingStatus.WaitingForWagon },
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
@@ -4928,6 +4950,7 @@ export class TrainSchedulingService {
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
stops,
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
@@ -9362,8 +9385,7 @@ export class TrainSchedulingService {
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
if (containerBookings.some((b) => assignableSet.has(b.id)) && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
@@ -9371,12 +9393,14 @@ export class TrainSchedulingService {
}
try {
// No placements handed over on purpose: the assign re-plans the WHOLE
// linked set (incl. non-eligible linked bookings such as one already in
// transit), so slot numbers from THIS preview would not line up with the
// plan it builds — it auto-fills every unit against its own plan instead.
// The preview placements above only serve the missing-number check.
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
{ bookingIds: assignableIds, keepDeferredLinked: true },
undefined,
);
result.assignedBookingIds = assignableIds;
@@ -10044,8 +10068,11 @@ export class TrainSchedulingService {
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
// Count the fleet at the yard the BOOKING boards from, not the train's
// origin: on a split consist a Mojo booking can only ride Mojo wagons, and
// Gelan's 31 spare wagons told a paying customer there was no shortage.
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
booking.originYardId ?? schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(

View File

@@ -443,3 +443,50 @@ describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', ()
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
});
});
describe('planWagonsWithStock — consist split across yards', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const boards = (id: string, quantity: number, originYardId: string): Booking =>
({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking;
const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() };
const legsFor = (bookings: Booking[]) =>
new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }]));
const splitStock = {
mode: 'TRAIN' as const,
remainingByTypeId: new Map([[nw6.id, 46]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
byYardId: new Map([
[GMP, new Map([[nw6.id, 31]])],
[MOJO, new Map([[nw6.id, 15]])],
]),
};
it('seats a boarding yard only from the wagons planned there', () => {
// 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons;
// BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits
// (the whole-train 46 is irrelevant).
const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']);
expect(result.deferred[0]!.reason).toContain('planned at the boarding yard');
expect(result.plan).toHaveLength(16);
});
it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => {
const stock = {
...splitStock,
remainingByTypeId: new Map([[nw6.id, 1]]),
byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]),
};
const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
// The Mojo wagon has TEU room, but it is not standing in Gelan.
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
});
});

View File

@@ -93,6 +93,12 @@ type OpenSlot = {
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
/**
* Boarding-yard pool this wagon was opened from — the yard the consist plans
* it at (`''` when the consist is not split across yards). A Mojo wagon
* cannot later be stretched back to board at Gelan.
*/
pool: string;
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -197,9 +203,19 @@ export function planWagonsWithStock(params: {
*/
legs?: Map<string, BookingLeg>;
edgeCount?: number;
/**
* Ordered corridor stop ids, parallel to the edges. Required for a consist
* split across yards (`stock.byYardId`): a booking then draws ONLY from the
* wagons planned at the yard it boards from (`stops[leg.from]`) — the
* whole-train count would happily plan 17 Mojo wagons on a train that has
* 15 there and 31 in Gelan, and the physical pin then fails after the
* customer has paid.
*/
stops?: readonly string[];
}): FlexPlanResult {
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const stops = params.stops ?? [];
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
@@ -214,32 +230,45 @@ export function planWagonsWithStock(params: {
};
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
// Wagons of a type in use per corridor edge. A type is available for a leg
// when its busiest edge WITHIN that leg still has stock spare — the max over
// edges is the number of physical wagons the type needs simultaneously.
// Split consist: each boarding yard is its own pool of steel (mirrors
// WagonStockLedger). Single-yard consist / loose yard pool: one pool ''.
const poolOf = (leg: BookingLeg): string =>
stock.byYardId ? (stops[leg.from] ?? '') : '';
const rowKeyFor = (wagonTypeId: string, pool: string): string =>
pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
const totalFor = (wagonTypeId: string, pool: string): number =>
pool
? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0)
: (stock.remainingByTypeId.get(wagonTypeId) ?? 0);
// Wagons of a type in use per corridor edge, per pool. A type is available
// for a leg when its busiest edge WITHIN that leg still has stock spare — the
// max over edges is the number of physical wagons the type needs simultaneously.
const usedPerEdge = new Map<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
const usedRow = (key: string): number[] => {
let row = usedPerEdge.get(key);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
usedPerEdge.set(key, row);
}
return row;
};
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
const pool = poolOf(leg);
const total = totalFor(wagonTypeId, pool);
const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool));
if (!row) return total;
let busiest = 0;
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
return total - busiest;
};
const noStockMessage = (candidates: WagonType[]): string => {
const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`;
return poolOf(leg)
? `Train has no free ${codes} wagon planned at the boarding yard`
: `Train has no free ${codes} wagon left`;
};
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
@@ -251,7 +280,7 @@ export function planWagonsWithStock(params: {
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
}
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
@@ -261,7 +290,8 @@ export function planWagonsWithStock(params: {
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
const row = usedRow(chosen.id);
const pool = poolOf(leg);
const row = usedRow(rowKeyFor(chosen.id, pool));
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
@@ -271,6 +301,7 @@ export function planWagonsWithStock(params: {
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
pool,
};
openSlots.push(open);
return open;
@@ -290,8 +321,11 @@ export function planWagonsWithStock(params: {
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
// A pooled wagon boards where its yard is; it cannot be stretched back to
// an EARLIER stop (the steel is not there), only ridden further.
if (open.pool && leg.from < open.covered.from) return false;
const total = totalFor(open.slot.wagonTypeId, open.pool);
const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
@@ -303,7 +337,7 @@ export function planWagonsWithStock(params: {
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool));
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {