mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user