feat(train-scheduling): implement container movement between wagons

- Added functionality to move containers between wagons in the train scheduling system.
- Introduced  API endpoint and service method to handle container movement.
- Updated  component to support drag-and-drop for rearranging containers.
- Enhanced  to allow moving containers to other wagons via a context menu.
- Implemented UI feedback for container movement actions, including loading states and success/error notifications.
- Updated relevant types and constants to accommodate new container movement logic.
- Added tests for the rule engine to ensure proper handling of hazardous bookings.
This commit is contained in:
Marshal
2026-07-21 23:02:06 +00:00
parent 00a81fda15
commit 835c9e111c
35 changed files with 1896 additions and 154 deletions

View File

@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }> }>;
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(0);
expect(result.blocked).toHaveLength(1);
});
it('does not price containers off a rate configured for a different leg', async () => {
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.lineItems).toHaveLength(0);
});
// A mixed booking where only one container size has a configured rate must
// hard-block, not silently carry the unconfigured size for free.
it('blocks the unconfigured container size and prices the configured one', async () => {
const fortyOnly: Rate = {
...intercityContainerUsd,
id: 'rate-ct-40-only',
containerTypeId: 'ct-40',
} as Rate;
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
const booking = {
id: 'b-5',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [
{ containerTypeId: 'ct-40', quantity: 2 },
{ containerTypeId: 'ct-20', quantity: 3 },
],
});
expect(result.lineItems).toHaveLength(1);
expect(result.blocked).toHaveLength(1);
expect(result.blocked[0]).toContain('rate is configured');
});
});

View File

@@ -137,8 +137,12 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
const {
lineItems: baseLines,
usedRates: baseRates,
warnings: baseWarnings,
blocked: baseBlocked,
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -248,7 +252,7 @@ export class BookingPricingService {
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: ruleResult.hardBlocked,
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
overweightLines,
};
}
@@ -454,7 +458,12 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
): Promise<{
lineItems: PriceLineItemDto[];
usedRates: Rate[];
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
@@ -477,6 +486,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
@@ -500,11 +510,14 @@ export class BookingPricingService {
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate an unpriced line with a warning is recoverable; a
// silently mischarged one is not.
warnings.push(
// route's) rate, and never let an unpriced line through: a booking
// that ships a container type nobody configured a rate for would be
// carried for free. Hard-block instead — the customer drops the line
// or EDR configures the rate.
blocked.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
'the line was not priced.',
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
'to configure its rate for this origin → destination.',
);
continue;
}
@@ -587,10 +600,18 @@ export class BookingPricingService {
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
});
} else if (isBulk) {
// Same rule as container lines: bulk freight with no rate on this leg
// must not proceed unpriced.
blocked.push(
`No ${rateType} rate is configured for this route — the booking ` +
'cannot be priced. Ask EDR to configure the rate for this ' +
'origin → destination.',
);
}
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
}
/**

View File

@@ -320,6 +320,12 @@ export class ContractBookingService {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
// a positive total, so the zero-price gate below misses it — enforce
// the pricing hard blocks first. The catch below rolls everything back.
if (computed.hardBlocked.length > 0) {
throw new BadRequestException(computed.hardBlocked.join('; '));
}
// Reject a zero-price booking outright. A total of 0 means no contract rate
// matched the route/container (or the rate is unset), so the booking is not
// valid to ship or invoice. The catch below rolls back the row + its lines.
@@ -744,15 +750,20 @@ export class ContractBookingService {
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A zero price means no contract rate matches — roll the cargo back so
// the instance stays CLEARANCE_READY and can be completed again once
// the contract rates are fixed (the clearance work is not lost).
if (!(computed.totalAmount > 0)) {
// the contract rates are fixed (the clearance work is not lost). A
// pricing hard block (e.g. one of two container sizes has no rate)
// rolls back the same way: a partially-priced total is positive but
// the booking must not proceed.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
computed.hardBlocked.length > 0
? computed.hardBlocked.join('; ')
: 'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
@@ -1896,7 +1907,10 @@ export class ContractBookingService {
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors: [...scopeErrors, ...capacityErrors],
// Pricing hard blocks (missing rate for a container size / requested
// service) ride the capacity-errors channel so the form hard-blocks in
// the preview instead of failing at the create call.
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,

View File

@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
'OTHER',
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];

View File

@@ -0,0 +1,78 @@
import { RuleEngineService } from './rule-engine.service';
import type { BookingEvaluationInput } from './rule-engine.service';
import type { Rate } from './entities/rate.entity';
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
const hazardRate: Rate = {
id: 'rate-hazard',
rateType: 'HAZARD_SURCHARGE',
trigger: 'HAZARDOUS',
rateValue: 50,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
let ratesRepo: { findLiveRates: jest.Mock };
let service: RuleEngineService;
beforeEach(() => {
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
ratesRepo as never,
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
{} as never, // dataSource (unused by evaluate)
);
});
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
containers: [],
...overrides,
});
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
const result = await service.evaluate(input({ isHazardous: true }));
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('hazardous');
});
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
const result = await service.evaluate(input({ isHazardous: true }));
expect(result.hardBlocked).toHaveLength(0);
});
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
const result = await service.evaluate(input({}));
expect(result.hardBlocked).toHaveLength(0);
});
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
const result = await service.evaluate(
input({
containers: [
{
containerTypeId: 'ct-20',
quantity: 2,
vgmPerUnitTons: 10,
totalVgmTons: 20,
reeferQuantity: 1,
},
],
}),
);
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('reefer');
});
});

View File

@@ -28,6 +28,10 @@ import {
} from './interfaces/shipping-lines.repository.interface';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';
export interface BookingContainerEvalInput {
containerTypeId: string;
quantity: number;
@@ -245,6 +249,48 @@ export class RuleEngineService {
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
// A handling service the booking asks for (booking-level flag OR any
// per-container opt-in count) with no LIVE surcharge rate configured is a
// hard block — pricing would otherwise ship the service for free. System-
// derived charges (consolidation, overweight, shipping line, lashing) stay
// exempt: the customer never opted into those, so they must not block.
const requestedServices: Array<{
trigger: RateTrigger;
wanted: boolean;
label: string;
}> = [
{
trigger: 'HAZARDOUS',
wanted:
truthy(input.isHazardous) ||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
label: 'hazardous cargo',
},
{
trigger: 'REEFER',
wanted:
hasReefer ||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
label: 'refrigerated (reefer) cargo',
},
{
trigger: 'WITH_RETURN',
wanted:
truthy(input.withReturn) ||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
label: 'empty-container return',
},
];
for (const svc of requestedServices) {
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
hardBlocked.push(
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
`be priced with this service. Remove the ${svc.label} option or ` +
'ask EDR to configure its rate.',
);
}
}
for (const rate of surchargeRates) {
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
@@ -438,9 +484,6 @@ export class RuleEngineService {
hasLashing: boolean;
},
): boolean {
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';
switch (trigger) {
case 'HAZARDOUS':
return truthy(state.isHazardous);

View File

@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
});
});
describe('expireLeftoverExportDay — export day sweep', () => {
const exportSchedule = {
id: scheduleId,
direction: 'EXPORT',
originStationId: 'yard-origin',
destinationStationId: 'yard-dest',
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
};
let unacceptedSpy: jest.SpyInstance;
let poolSpy: jest.SpyInstance;
beforeEach(() => {
unacceptedSpy = jest
.spyOn(service, 'expireUnacceptedForRouteDay')
.mockResolvedValue(undefined);
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
});
it('ignores non-export schedules', async () => {
trainSchedulesRepository.findById.mockResolvedValue({
...exportSchedule,
direction: 'IMPORT',
});
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('defers while another export train on the day can still take bookings', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('defers while a FULL train still has live pay windows', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'FULL',
},
]);
bookingsRepository.findReservedForSchedule.mockResolvedValue([
{
paymentStatus: 'PENDING',
status: 'AWAITING_PAYMENT',
paymentDeadline: new Date(Date.now() + 60_000),
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'FULL',
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).toHaveBeenCalledWith({
originYardId: 'yard-origin',
destinationYardId: 'yard-dest',
day: '2026-06-20',
});
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
});
});
describe('maybeOfferPartial — split-eligibility gate', () => {
const importGeneral = {
id: 'b1',
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
);
});
});
describe('acceptIntercity — export pay window expires at window close', () => {
const exportScheduleId = 'export-train';
// Window closes in 30 minutes; the configured pay window is 60 minutes.
const closesAt = new Date(Date.now() + 30 * 60_000);
const waiting = {
id: 'ic-1',
reference: 'IC-1',
isGovernment: false,
status: 'FULLY_EXECUTED',
trainScheduleId: null,
freightType: 'CONTAINER',
cargoTotalWeightVgm: 10,
bookingContainers: [],
} as unknown as Booking;
let scheduleRepo: { findOne: jest.Mock };
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
beforeEach(() => {
bookingRepo = dataSource.getRepository();
bookingRepo.findOne.mockResolvedValue(waiting);
scheduleRepo = { findOne: jest.fn() };
// reserve() reads the target schedule to clamp export deadlines — route
// TrainSchedule reads to their own repo, everything else stays as before.
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
);
});
it('clamps the intercity pay deadline to the export window close', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: exportScheduleId,
direction: 'EXPORT',
windowClosesAt: closesAt,
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
});
await service.acceptIntercity(waiting, exportScheduleId);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'ic-1',
expect.objectContaining({
status: 'SELECTED_FOR_BATCH',
paymentDeadline: closesAt,
}),
);
expect(notifier.payNow).toHaveBeenCalledTimes(1);
});
it('keeps the plain payment window on import trains', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: 'import-train',
direction: 'IMPORT',
windowClosesAt: closesAt,
});
await service.acceptIntercity(waiting, 'import-train');
const deadline = (
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
).paymentDeadline;
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
});
it('rejects an accept after the export window closed — no pay window opens', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: exportScheduleId,
direction: 'EXPORT',
windowClosesAt: new Date(Date.now() - 60_000),
});
await expect(
service.acceptIntercity(waiting, exportScheduleId),
).rejects.toThrow(/window has closed/);
expect(bookingsRepository.update).not.toHaveBeenCalled();
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('expires an unpaid export ride-along at close and frees the train', async () => {
const lapsed = {
...(waiting as unknown as Record<string, unknown>),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
paymentDeadline: new Date(Date.now() - 1_000),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,
wagonsRequired: 1,
} as unknown as Booking;
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
bookingRepo.findOne.mockResolvedValue(lapsed);
trainSchedulesRepository.findById.mockResolvedValue({
id: exportScheduleId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
originStationId: 'yard-a',
destinationStationId: 'yard-b',
});
await service.settleDueReservations(exportScheduleId);
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'ic-1',
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
);
});
});
});
describe('BookingBatchService — wagonsFor', () => {

View File

@@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit {
);
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
// This payment may have been the last live pay window on a now-full
// export day — the settle that normally re-runs the sweep finds nothing
// left to settle, so trigger it here.
void this.expireLeftoverExportDay(booking.trainScheduleId);
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit {
`— payment phase extended for them`,
);
}
// The settle may have resolved the last pay window on a full export day
// (paid → allocated, and the top-up found nothing else that fits) — sweep
// the date's leftover bookings. Self-guarded: no-op for import/domestic
// and while any train on the day can still take bookings.
await this.expireLeftoverExportDay(scheduleId);
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
// this) so one settle produces one push, after every allocation/expiry/
// top-up extension for this schedule has been persisted.
@@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit {
);
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
// Same as the webhook path: a staff mark-paid can settle the last live
// pay window on a now-full export day — sweep the date's leftovers.
void this.expireLeftoverExportDay(booking.trainScheduleId);
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
@@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
// Built trains: collapse to a single train-wide pool so the freed capacity of
// a booking that alights mid-corridor is NOT re-offered on the pass-through
// leg (see remainingBudget). Keeps intercity accept consistent with the
// train-wide isTrainFull / committedWagons finalize signal.
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
collapseForBuiltTrain: true,
});
// Built trains use the leg-aware corridor budget too: the wagon planner
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
// that runs empty Gelan→Adama genuinely can carry an intercity booking
// there before its export cargo boards at Adama. A train full on one leg
// still accepts ride-alongs on its empty legs — that is the whole point
// of the ride-along flow.
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
@@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit {
return;
}
const now = new Date();
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
// EXPORT parity: pay windows on an export train never outlive its booking
// window — export bookings expire at close, so anything reserved onto the
// same train (FCFS export or an intercity ride-along) must too. Import
// keeps the plain payment window; its cycles re-fill after settle.
const targetSchedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (targetSchedule?.direction === "EXPORT") {
const cutoff =
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
if (cutoff && cutoff.getTime() <= now.getTime()) {
throw new BadRequestException(
"Export booking window has closed — cannot open a pay window on this train",
);
}
if (cutoff && cutoff.getTime() < deadline.getTime()) {
deadline = new Date(cutoff);
}
}
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
@@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit {
return leftovers.length;
}
/**
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
* so nothing ever concluded its day: bookings still waiting when the trains
* filled up or the window closed stayed pending forever. Once every export
* train on this route-day is shut — window DONE, or FULL with no pay window
* still live that could lapse and free space — the date is dead: expire the
* un-accepted bookings staff can no longer accept AND the ready
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
* waiters). Runs at export window close and whenever an export train's
* fullness settles.
*/
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
return;
}
const day = eatDay(schedule.scheduledDepartureDate);
const trains = (
await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
})
).filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day,
);
for (const s of trains) {
// Any train still taking bookings keeps the date alive.
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
// A FULL train whose reservations are still inside their pay windows can
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
return;
}
}
await this.expireUnacceptedForRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day,
});
await this.expireLeftoverDayPool(scheduleId);
}
/**
* Union of stop yards across the day's fillable schedules on this corridor —
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
@@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit {
schedule: TrainSchedule,
limits: TrainLimits,
wagonDims: WagonDims,
opts?: { collapseForBuiltTrain?: boolean },
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
@@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit {
tolerance: { weightTons: 0, lengthMeters: 0 },
};
}
// A built train's wagons are coupled for the WHOLE trip, and the allocator
// commits each booking to a wagon for the entire route — it never reloads a
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
// its capacity is one train-wide pool, exactly as isTrainFull /
// committedWagons already count it. When a caller opts in, collapse the
// corridor to a single whole-route edge so every booking (full-route OR
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
// leg it merely passes through, instead of over-promising the freed slots.
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
// abstract slot/weight/length budget genuinely frees past an alight yard.
const stops =
physicalWagons != null && opts?.collapseForBuiltTrain
? [schedule.originStationId, schedule.destinationStationId]
: await this.stopsForSchedule(schedule);
// Built trains keep the leg-aware multi-edge corridor too: the wagon
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
// 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);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)

View File

@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock;
expireLeftoverExportDay: jest.Mock;
fillFromWaitingList: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
@@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: jest.fn().mockResolvedValue(0),
};

View File

@@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
schedule.bookingWindowStatus = 'CLOSED';
}
// Export has no conclude step: this close is the last moment the day's
// bookings could have boarded. Once every train on the route-day is
// shut, expire what is still waiting for this date (the sweep defers
// while a sibling train stays open).
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
return true;
}
return false;

View File

@@ -0,0 +1,14 @@
import { IsOptional, IsUUID } from 'class-validator';
export class MoveContainerItemDto {
@IsUUID()
targetTrainSetWagonId!: string;
/**
* Swap with this container on the target wagon instead of requiring free
* space there. Same-wagon swaps exchange the two slot positions.
*/
@IsUUID()
@IsOptional()
swapWithItemId?: string;
}

View File

@@ -146,10 +146,8 @@ export class IntercityService {
remaining: capacity?.budget.maxRemaining() ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
// legForYards, not legOf: on a built train the budget is a single
// whole-route edge (see intercityCapacity), so a mid-corridor booking
// must draw from that one pool via the whole-route fallback. On a
// locomotive-derived schedule it still resolves to the booking's own leg.
// legForYards: the booking draws only from ITS OWN leg's edges, with a
// whole-route fallback when its yards aren't on the budget's stop list.
const leg = capacity?.budget.legForYards(
booking.originYardId,
booking.destinationYardId,
@@ -215,11 +213,8 @@ export class IntercityService {
continue;
}
const need = capacity.needFor(booking);
// legForYards, not legOf: a built train's budget is a single whole-route
// pool (mid-corridor wagons are committed for the whole trip and never
// reloaded), so the booking draws from that pool via the whole-route
// fallback; a locomotive-derived schedule still gets the booking's own
// leg, so it can still board a train that is full only on other legs.
// legForYards: charge only the edges this booking rides, so it can still
// board a train that is full only on other legs.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!budget.fits(need, leg)) {
rejected.push({

View File

@@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { MoveContainerItemDto } from "./dto/move-container-item.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
@@ -374,6 +375,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
}
@Post("schedules/:id/container-items/:itemId/move")
@TrainSchedulingManage()
@ApiOperation({
summary: "Move a container to another wagon (optionally swapping two containers)",
})
moveContainerItem(
@Param("id", ParseUUIDPipe) id: string,
@Param("itemId", ParseUUIDPipe) itemId: string,
@Body() dto: MoveContainerItemDto,
) {
return this.trainSchedulingService.moveContainerItem(id, itemId, dto);
}
@Get("schedules/:id/unassigned-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })

View File

@@ -5,6 +5,7 @@ import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -1081,4 +1082,143 @@ describe('TrainSchedulingService', () => {
expect(html).not.toContain('EMPTY');
});
});
describe('moveContainerItem — staff rearrange', () => {
const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 };
const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 };
const schedule = {
id: 'sched-1',
status: 'SCHEDULED',
trainSet: { wagons: [wagon1, wagon2] },
};
let sourceAlloc: Record<string, unknown>;
let item: Record<string, unknown>;
let itemRepo: { findOne: jest.Mock; update: jest.Mock; count: jest.Mock };
let allocRepo: {
find: jest.Mock;
findOne: jest.Mock;
create: jest.Mock;
save: jest.Mock;
update: jest.Mock;
delete: jest.Mock;
};
let wagon2Allocs: Array<Record<string, unknown>>;
beforeEach(() => {
sourceAlloc = {
id: 'alloc-1',
trainSetWagonId: 'w1',
bookingId: 'b1',
allocatedWeightTons: 20,
loadType: 'CONTAINER',
status: 'PLANNED',
containerItems: [],
};
item = {
id: 'item-1',
wagonBookingAllocationId: 'alloc-1',
positionOnWagon: 1,
grossWeightTons: 20,
containerType: { sizeFt: 20 },
allocation: sourceAlloc,
};
sourceAlloc.containerItems = [item];
wagon2Allocs = [];
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
itemRepo = {
findOne: jest.fn().mockResolvedValue(item),
update: jest.fn().mockResolvedValue(undefined),
count: jest.fn().mockResolvedValue(0),
};
allocRepo = {
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs),
),
findOne: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) =>
Promise.resolve(where.id === 'alloc-1' ? { ...sourceAlloc } : null),
),
create: jest.fn((v: unknown) => v),
save: jest.fn().mockImplementation((v: Record<string, unknown>) =>
Promise.resolve({ ...v, id: 'alloc-new' }),
),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonAllocationContainerItem) return itemRepo;
if (entity === WagonBookingAllocation) return allocRepo;
return { find: jest.fn().mockResolvedValue([]) };
});
dataSource.transaction.mockImplementation(
async (fn: (m: unknown) => Promise<void>) =>
fn({ getRepository: dataSource.getRepository }),
);
jest
.spyOn(
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
'getTrainScheduleById' as never,
)
.mockResolvedValue({ id: 'sched-1' } as never);
});
it('rejects moves on a dispatched train', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
...schedule,
status: 'DISPATCHED',
});
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a target wagon that has no TEU room left', async () => {
wagon2Allocs = [
{
id: 'alloc-2',
trainSetWagonId: 'w2',
bookingId: 'b2',
allocatedWeightTons: 25,
loadType: 'CONTAINER',
containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }],
},
];
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(/no room/);
});
it('rejects a bulk-loaded target wagon', async () => {
wagon2Allocs = [
{
id: 'alloc-2',
trainSetWagonId: 'w2',
bookingId: 'b2',
allocatedWeightTons: 40,
loadType: 'BULK',
containerItems: [],
},
];
await expect(
service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }),
).rejects.toThrow(/bulk/);
});
it('moves a container to an empty wagon and re-homes its allocation', async () => {
await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' });
// A new allocation for the booking was created on the target wagon…
expect(allocRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }),
);
// …the container item now hangs off it…
expect(itemRepo.update).toHaveBeenCalledWith('item-1', {
wagonBookingAllocationId: 'alloc-new',
});
// …and the emptied source allocation was deleted, not left at 0 items.
expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1');
});
});
});

View File

@@ -77,6 +77,7 @@ import {
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { MoveContainerItemDto } from './dto/move-container-item.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
@@ -122,7 +123,7 @@ import {
sumWagonsRequired,
type TrainLimitConfig,
validateContainerPlacements,
validateMixedTrainLimits,
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -1538,8 +1539,21 @@ export class TrainSchedulingService {
}
}
// The rebuild below deletes EVERY schedule↔booking link row and recreates
// only what makes the new plan. Ride-along (intercity) bookings are linked
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
// workspace's picked ids, so planning from dto.bookingIds alone silently
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
// every list. Every (re)assignment therefore re-plans the WHOLE train:
// the requested ids plus everything currently linked.
const linkedRows =
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
const allBookingIds = [
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
];
const previewDto = {
bookingIds: dto.bookingIds,
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
@@ -1562,8 +1576,13 @@ export class TrainSchedulingService {
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
// Callers hand-pick placements only for the bookings they know about; the
// union above may have folded in linked ride-alongs those placements never
// covered. Auto-fill whatever units are missing (all of them when no
// placements were sent at all) so the placement validator doesn't reject
// container bookings the caller couldn't have placed.
let containerPlacements = dto.containerPlacements;
if (!containerPlacements?.length) {
{
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -1578,18 +1597,28 @@ export class TrainSchedulingService {
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
const providedKeys = new Set(
(containerPlacements ?? []).map(
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
),
);
const unplacedUnits = units.filter(
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = [...(containerPlacements ?? []), ...generated];
}
containerPlacements = generated;
}
}
@@ -1632,8 +1661,10 @@ export class TrainSchedulingService {
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
// A stock shortage is a physical impossibility, so forceAssign cannot
// override it either.
// 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 = dto.bookingIds.filter((id) => !plannedIds.has(id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
@@ -3852,25 +3883,24 @@ export class TrainSchedulingService {
);
}
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
// stop list is also what makes the wagon plan leg-aware below.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
}
if (
await (async () => {
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
return bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
});
})()
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
})
) {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
@@ -3956,7 +3986,22 @@ export class TrainSchedulingService {
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const planned = planWagonsWithStock({ bookings, allowed, stock });
// 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.
const legByBookingId = new Map(
bookings.flatMap((b) => {
const from = stops.indexOf(b.originYardId);
const to = stops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
const planned = planWagonsWithStock({
bookings,
allowed,
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
@@ -4018,10 +4063,11 @@ export class TrainSchedulingService {
).values(),
];
pushLimit(
validateMixedTrainLimits(
validateMixedTrainLimitsPerEdge(
wagonPlan,
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
trainLimits,
stops,
),
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
@@ -6810,12 +6856,33 @@ export class TrainSchedulingService {
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Which leg of the corridor this booking rides — the workspace can't
// tell a ride-along (intercity) or sub-corridor booking from through
// cargo without it.
tradeDirection: sb.booking?.tradeDirection ?? null,
originYardId: sb.booking?.originYardId ?? null,
destinationYardId: sb.booking?.destinationYardId ?? null,
origin:
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
destination:
sb.booking?.destinationYard?.label ??
sb.booking?.destinationYard?.code ??
null,
wagonsRequired:
sb.booking?.wagonsRequired != null
? Number(sb.booking.wagonsRequired)
: null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
@@ -6824,6 +6891,42 @@ export class TrainSchedulingService {
};
}
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
private mapScheduleStops(
schedule: TrainSchedule,
): Array<{ yardId: string; label: string }> {
const milestones = [...(schedule.route?.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const raw = milestones.length >= 2
? milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}))
: [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
const seen = new Set<string>();
return raw.filter((stop) => {
if (!stop.yardId || seen.has(stop.yardId)) return false;
seen.add(stop.yardId);
return true;
});
}
private isHoldActive(booking: Booking): boolean {
if (!booking.holdExpiresAt) return false;
return booking.holdExpiresAt.getTime() > Date.now();
@@ -7232,6 +7335,232 @@ export class TrainSchedulingService {
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
/**
* Staff rearrange: move one container to another wagon of the same train, or
* swap two containers (cross-wagon, or same-wagon to exchange slot positions).
* Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two
* 20ft) and the wagon's rated payload is never exceeded — so a drag on the
* consist can't silently overload a wagon. Allocation rows follow the items:
* the booking gets an allocation on the target wagon (created if missing),
* weights shift with the container, and an allocation left with no items is
* deleted.
*/
async moveContainerItem(
scheduleId: string,
itemId: string,
dto: MoveContainerItemDto,
): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
throw new BadRequestException('Cannot rearrange containers on a dispatched train');
}
const wagonById = new Map((schedule.trainSet?.wagons ?? []).map((w) => [w.id, w]));
const itemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
const item = await itemRepo.findOne({
where: { id: itemId },
relations: { allocation: true, containerType: true },
});
const sourceWagon = item?.allocation
? wagonById.get(item.allocation.trainSetWagonId)
: undefined;
if (!item?.allocation || !sourceWagon) {
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
}
const targetWagon = wagonById.get(dto.targetTrainSetWagonId);
if (!targetWagon) {
throw new NotFoundException('Target wagon is not part of this schedule');
}
const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({
where: { trainSetWagonId },
relations: { containerItems: { containerType: true } },
});
const [sourceAllocs, targetAllocs] = await Promise.all([
loadAllocations(sourceWagon.id),
loadAllocations(targetWagon.id),
]);
if (
targetAllocs.some((a) => (a.loadType ?? '').toUpperCase().includes('BULK'))
) {
throw new BadRequestException(
`Wagon #${targetWagon.sequenceNo} carries a bulk load — containers cannot ride it`,
);
}
const swapItem = dto.swapWithItemId
? targetAllocs
.flatMap((a) => a.containerItems ?? [])
.find((it) => it.id === dto.swapWithItemId)
: undefined;
if (dto.swapWithItemId && !swapItem) {
throw new BadRequestException('The container to swap with is not on the target wagon');
}
if (swapItem?.id === item.id) {
throw new BadRequestException('Cannot swap a container with itself');
}
if (sourceWagon.id === targetWagon.id && !swapItem) {
return this.getTrainScheduleById(scheduleId);
}
// TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One
// wagon never exceeds 2 TEU — the same rule the auto-allocation packs by.
const MAX_TEU_PER_WAGON = 2;
const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) =>
(it.containerType?.sizeFt ?? 20) >= 40 ? 2 : 1;
const itemsOf = (allocs: WagonBookingAllocation[]) =>
allocs.flatMap((a) => a.containerItems ?? []);
// Weight a container carries into the move: its own gross when recorded,
// otherwise an even share of its allocation's weight.
const weightOf = (
it: WagonAllocationContainerItem,
alloc: WagonBookingAllocation,
siblings: number,
) =>
Number(it.grossWeightTons) ||
Number(alloc.allocatedWeightTons) / Math.max(1, siblings);
const sourceAlloc = sourceAllocs.find((a) => a.id === item.wagonBookingAllocationId);
if (!sourceAlloc) {
throw new NotFoundException(`Container item ${itemId} not found on this schedule`);
}
const itemWeight = weightOf(item, sourceAlloc, (sourceAlloc.containerItems ?? []).length);
const swapAlloc = swapItem
? targetAllocs.find((a) => a.id === swapItem.wagonBookingAllocationId)
: undefined;
const swapWeight =
swapItem && swapAlloc
? weightOf(swapItem, swapAlloc, (swapAlloc.containerItems ?? []).length)
: 0;
if (sourceWagon.id !== targetWagon.id) {
const targetTeu = itemsOf(targetAllocs)
.filter((it) => it.id !== swapItem?.id)
.reduce((sum, it) => sum + teuOf(it), 0);
if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) {
throw new BadRequestException(
`Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`,
);
}
if (swapItem) {
const sourceTeu = itemsOf(sourceAllocs)
.filter((it) => it.id !== item.id)
.reduce((sum, it) => sum + teuOf(it), 0);
if (sourceTeu + teuOf(swapItem) > MAX_TEU_PER_WAGON) {
throw new BadRequestException(
`Wagon #${sourceWagon.sequenceNo} has no room for the swapped container — a wagon holds one 40ft or two 20ft containers`,
);
}
}
const cargoOn = (allocs: WagonBookingAllocation[]) =>
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
const checkPayload = (
wagon: { sequenceNo: number; capacityTons?: number | null },
cargoAfter: number,
) => {
const capacity = Number(wagon.capacityTons ?? 0);
if (capacity > 0 && cargoAfter > capacity + 0.001) {
throw new BadRequestException(
`Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`,
);
}
};
checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight);
if (swapItem) {
checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight);
}
}
await this.dataSource.transaction(async (manager) => {
const items = manager.getRepository(WagonAllocationContainerItem);
const allocs = manager.getRepository(WagonBookingAllocation);
// Same-wagon swap: the containers only trade slot positions.
if (sourceWagon.id === targetWagon.id && swapItem) {
const a = item.positionOnWagon ?? null;
const b = swapItem.positionOnWagon ?? null;
await items.update(item.id, { positionOnWagon: b });
await items.update(swapItem.id, { positionOnWagon: a });
return;
}
const moveOne = async (
moving: WagonAllocationContainerItem,
toWagonId: string,
weight: number,
) => {
// Re-read the source allocation — the other leg of a swap may have
// already shifted weight on it within this transaction.
const from = await allocs.findOne({
where: { id: moving.wagonBookingAllocationId },
});
if (!from) return;
let to = await allocs.findOne({
where: { trainSetWagonId: toWagonId, bookingId: from.bookingId },
});
if (!to) {
to = await allocs.save(
allocs.create({
trainSetWagonId: toWagonId,
bookingId: from.bookingId,
allocatedWeightTons: 0,
loadType: from.loadType ?? 'CONTAINER',
status: from.status ?? 'PLANNED',
}),
);
}
await items.update(moving.id, { wagonBookingAllocationId: to.id });
await allocs.update(to.id, {
allocatedWeightTons: roundTons(Number(to.allocatedWeightTons) + weight),
});
const remaining = await items.count({
where: { wagonBookingAllocationId: from.id },
});
if (remaining === 0) {
await allocs.delete(from.id);
} else {
await allocs.update(from.id, {
allocatedWeightTons: roundTons(
Math.max(0, Number(from.allocatedWeightTons) - weight),
),
});
}
};
await moveOne(item, targetWagon.id, itemWeight);
if (swapItem) {
await moveOne(swapItem, sourceWagon.id, swapWeight);
}
// Keep slot positions dense (1..n) on both touched wagons.
const renumber = async (trainSetWagonId: string) => {
const wagonAllocs = await allocs.find({
where: { trainSetWagonId },
relations: { containerItems: true },
});
const wagonItems = wagonAllocs
.flatMap((a) => a.containerItems ?? [])
.sort((x, y) => (x.positionOnWagon ?? 99) - (y.positionOnWagon ?? 99));
for (let i = 0; i < wagonItems.length; i += 1) {
if (wagonItems[i].positionOnWagon !== i + 1) {
await items.update(wagonItems[i].id, { positionOnWagon: i + 1 });
}
}
};
await renumber(sourceWagon.id);
await renumber(targetWagon.id);
});
return this.getTrainScheduleById(scheduleId);
}
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {

View File

@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
});
});
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
};
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
new Map(entries);
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.deferred).toHaveLength(0);
expect(result.fitting.map((b) => b.id).sort()).toEqual([
'EXPORT-1',
'INTERCITY-1',
]);
// Two slots planned, but both drawn from the single physical wagon.
expect(result.plan).toHaveLength(2);
});
it('still defers when the legs overlap and stock is exhausted', () => {
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
// Both ride edge 0 — they compete for the one wagon.
['EXPORT-1', { from: 0, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
});
it('never packs bookings with different legs into the same wagon slot', () => {
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
// must open separate slots (each with its own leg), not one mixed slot.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 2]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.plan).toHaveLength(2);
const bookingsPerSlot = result.plan.map((s) =>
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
);
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
});
it('behaves exactly like the whole-route planner when no legs are given', () => {
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
expect(result.deferred).toHaveLength(0);
expect(result.plan).toHaveLength(1);
});
});

View File

@@ -58,8 +58,18 @@ type OpenSlot = {
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
cargoTypeId: string | null;
freeCapacityTons: number;
/**
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
* share a slot when their legs are identical — mixing corridors in one slot
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
* re-occupy edges the cargo never rides.
*/
legKey: string;
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
export type BookingLeg = { from: number; to: number };
type PlacementProblem = {
kind: 'config' | 'stock';
message: string;
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
const shortageFor = (
booking: Booking,
candidates: WagonType[],
remaining: Map<string, number>,
availableOf: (wagonTypeId: string) => number,
): BookingWagonShortage => {
const wagonsNeeded =
booking.freightType === 'BULK'
@@ -100,7 +110,7 @@ const shortageFor = (
)
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
const wagonsAvailable = candidates.reduce(
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
(sum, wt) => sum + availableOf(wt.id),
0,
);
return {
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
bookings: Booking[];
allowed: AllowedWagonTypeMap;
stock: WagonStock;
/**
* Leg-aware stock: booking id → the stop-index range it rides. When given
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
* the same physical wagon can serve an intercity booking on Gelan→Adama and
* an export booking on Adama→Doraleh — disjoint legs never compete for
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
*/
legs?: Map<string, BookingLeg>;
edgeCount?: number;
}): FlexPlanResult {
const { bookings, allowed, stock } = params;
const remaining = new Map(stock.remainingByTypeId);
const { bookings, allowed, stock, legs } = params;
const edgeCount = Math.max(1, params.edgeCount ?? 1);
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>();
const legFor = (booking: Booking): BookingLeg => {
const leg = legs?.get(booking.id);
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
return { from: 0, to: edgeCount };
}
return leg;
};
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.
const usedPerEdge = new Map<string, number[]>();
const usedRow = (wagonTypeId: string): number[] => {
let row = usedPerEdge.get(wagonTypeId);
if (!row) {
row = new Array<number>(edgeCount).fill(0);
usedPerEdge.set(wagonTypeId, row);
}
return row;
};
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = usedPerEdge.get(wagonTypeId);
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 codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
: `No available ${codes} wagon at the yard`;
};
/** Open a new wagon of one of the candidate types, consuming stock. */
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
const openSlot = (
candidates: WagonType[],
kind: SlotLoadType,
cargoTypeId: string | null,
leg: BookingLeg,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
if (!inStock.length) {
return { kind: 'stock', message: noStockMessage(candidates), candidates };
}
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
const chosen = [...inStock].sort((a, b) =>
kind === 'BULK'
? Number(b.capacityTons) - Number(a.capacityTons) ||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
availableFor(b.id, leg) - availableFor(a.id, leg)
: availableFor(b.id, leg) - availableFor(a.id, leg),
)[0];
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
const row = usedRow(chosen.id);
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
};
openSlots.push(open);
return open;
};
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
const leg = legFor(booking);
const legKey = legKeyOf(leg);
if (booking.freightType === 'CONTAINER') {
const units = expandBookingContainerUnits([booking]);
if (!units.length) {
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
open.legKey === legKey &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
if (!target) {
const openedSlot = openSlot(candidates, 'CONTAINER', null);
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
if ('message' in openedSlot) return openedSlot;
target = openedSlot;
}
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
for (const open of openSlots) {
if (remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.legKey !== legKey) continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
}
while (remainingWeight > 0 || !placedAnywhere) {
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
if ('message' in openedSlot) return openedSlot;
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
addAllocation(
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
for (const booking of sortBookingsForScheduling(bookings)) {
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
const remainingSnapshot = new Map(remaining);
const usedSnapshot = new Map(
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
);
const slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
}
// Roll back this booking's partial placements.
remaining.clear();
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
usedPerEdge.clear();
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
openSlots.length = slotCountSnapshot;
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
});
if (problem.kind === 'config') configIssues.add(problem.message);
// remaining is rolled back here, so the shortage counts the stock this
// Usage is rolled back here, so the shortage counts the stock this
// booking actually saw — not what its own partial placement consumed.
const bookingLeg = legFor(booking);
const shortage =
problem.kind === 'stock' && problem.candidates?.length
? shortageFor(booking, problem.candidates, remaining)
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
)
: null;
deferred.push({
id: booking.id,

View File

@@ -525,6 +525,40 @@ export function validateMixedTrainLimits(
);
}
/**
* Leg-aware limit check: with a real stop list, a slot only counts on the
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
* own endpoint). Each edge is validated as its own consist, so an intercity
* wagon on Gelan→Adama never counts against a train that is full only on
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
*/
export function validateMixedTrainLimitsPerEdge(
wagonPlan: WagonPlanSlot[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits: TrainLimitConfig | undefined,
stops: string[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const lastIdx = stops.length - 1;
const spans = wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
// A yard missing from the stop list keeps the slot on the whole route.
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
const violations = new Set<string>();
for (let edge = 0; edge < lastIdx; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
violations.add(violation);
}
}
return [...violations];
}
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],