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

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
marshal
2026-07-18 02:27:50 +03:00
committed by GitHub
59 changed files with 1919 additions and 140 deletions

View File

@@ -6,6 +6,8 @@ import {
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
computeImportWindowTimes,
computeExportWindowTimes,
bookingCloseCutoff,
type BoardWindowConfig,
} from './batch-window.util';
@@ -360,3 +362,101 @@ describe('computeImportWindowTimes — immediate open inside the window day', ()
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
});
// Booking-close offset: a configured offset pulls the window close earlier than
// departure by that many minutes, separately for import and export.
describe('bookingCloseCutoff — departure offset', () => {
const departure = new Date('2026-07-10T13:00:00.000Z'); // 16:00 EAT Jul 10
it('returns departure unchanged when no offset is set', () => {
expect(bookingCloseCutoff(departure, 'IMPORT', {}).toISOString()).toBe(
departure.toISOString(),
);
expect(
bookingCloseCutoff(departure, 'EXPORT', {
importCloseOffsetMinutes: 180,
}).toISOString(),
).toBe(departure.toISOString());
});
it('a non-positive offset is treated as no offset', () => {
expect(
bookingCloseCutoff(departure, 'IMPORT', {
importCloseOffsetMinutes: 0,
}).toISOString(),
).toBe(departure.toISOString());
expect(
bookingCloseCutoff(departure, 'IMPORT', {
importCloseOffsetMinutes: -5,
}).toISOString(),
).toBe(departure.toISOString());
});
it('import 3-hour offset: 16:00 EAT departure → cutoff 13:00 EAT (14:00 → 3h before)', () => {
// Departure 16:00 EAT (13:00 UTC), 3h offset → 13:00 EAT = 10:00 UTC.
const cutoff = bookingCloseCutoff(departure, 'IMPORT', {
importCloseOffsetMinutes: 180,
});
expect(cutoff.toISOString()).toBe('2026-07-10T10:00:00.000Z');
});
it('export 1-day offset: Jul-10 16:00 EAT departure → cutoff Jul-9 16:00 EAT', () => {
const cutoff = bookingCloseCutoff(departure, 'EXPORT', {
exportCloseOffsetMinutes: 1440,
});
// Jul 9 16:00 EAT = Jul 9 13:00 UTC.
expect(cutoff.toISOString()).toBe('2026-07-09T13:00:00.000Z');
});
it('import and export offsets are independent', () => {
const cfg = {
importCloseOffsetMinutes: 180,
exportCloseOffsetMinutes: 1440,
};
expect(bookingCloseCutoff(departure, 'IMPORT', cfg).toISOString()).toBe(
'2026-07-10T10:00:00.000Z',
);
expect(bookingCloseCutoff(departure, 'EXPORT', cfg).toISOString()).toBe(
'2026-07-09T13:00:00.000Z',
);
// DOMESTIC uses the import offset.
expect(bookingCloseCutoff(departure, 'DOMESTIC', cfg).toISOString()).toBe(
'2026-07-10T10:00:00.000Z',
);
});
});
describe('window-time computation honours the close offset', () => {
it('export closes at departure offset, not departure', () => {
// Departs Jul 10 16:00 EAT (13:00 UTC), lead 24h, 24-hour desk, 1-day offset.
const departure = new Date('2026-07-10T13:00:00.000Z');
const { windowClosesAt } = computeExportWindowTimes(departure, {
exportBookingLeadHours: 48,
windowOpenHour: 8,
windowCloseHour: 8, // 24-hour desk
exportCloseOffsetMinutes: 1440,
});
// Jul 9 16:00 EAT = Jul 9 13:00 UTC.
expect(windowClosesAt.toISOString()).toBe('2026-07-09T13:00:00.000Z');
});
it('import close is capped at the cutoff (departure offset)', () => {
// Round-the-clock desk, opens 05 Jul 12:00 EAT, 24h duration would run to
// 06 Jul 12:00; departure 06 Jul 08:00 EAT (05:00 UTC) with a 2-hour offset →
// cutoff 06 Jul 06:00 EAT = 03:00 UTC.
const departure = new Date('2026-07-06T05:00:00.000Z');
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
{
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 8, // 24-hour desk (no office-hour cap)
windowDurationHours: 24,
importCloseOffsetMinutes: 120,
},
now,
);
expect(windowClosesAt.toISOString()).toBe('2026-07-06T03:00:00.000Z');
});
});

View File

@@ -266,6 +266,29 @@ export function clampCloseToOfficeHours(
return closesAt;
}
/**
* The instant a schedule stops accepting bookings. By default that is departure,
* but a configured close offset (import/export, minutes) pulls it earlier:
* `departure offset`. This is the single bound every window close, reopen
* cycle and export FCFS close is capped at — swap it in wherever the logic used
* to cap at departure. A non-positive/absent offset yields departure unchanged.
*/
export function bookingCloseCutoff(
departure: Date,
direction: string | null | undefined,
cfg: {
importCloseOffsetMinutes?: number | null;
exportCloseOffsetMinutes?: number | null;
},
): Date {
const offsetMinutes =
direction === 'EXPORT'
? cfg.exportCloseOffsetMinutes
: cfg.importCloseOffsetMinutes;
if (offsetMinutes == null || !(offsetMinutes > 0)) return departure;
return new Date(departure.getTime() - offsetMinutes * 60_000);
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -296,9 +319,13 @@ export function computeImportWindowTimes(
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
importCloseOffsetMinutes?: number | null;
},
now: Date,
): InitialWindowTimes {
// The window opens off the REAL departure (open day = departure leadDays),
// but shuts at the configured cutoff (departure closeOffset, or departure).
const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg);
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);
@@ -324,8 +351,8 @@ export function computeImportWindowTimes(
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
if (closesAt.getTime() > cutoff.getTime()) {
closesAt = cutoff;
}
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
@@ -344,8 +371,12 @@ export function computeExportWindowTimes(
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
exportCloseOffsetMinutes?: number | null;
},
): InitialWindowTimes {
// Opens off the real departure (lead hours), shuts at the cutoff
// (departure closeOffset, or departure when no offset is set).
const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg);
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
@@ -353,10 +384,12 @@ export function computeExportWindowTimes(
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
// Open can't outlive the cutoff (a huge offset would otherwise leave a
// negative-length window); clamp to a zero-length window at the cutoff.
if (opensAt.getTime() > cutoff.getTime()) {
opensAt = cutoff;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
return { windowOpensAt: opensAt, windowClosesAt: cutoff };
}
/**
@@ -457,6 +490,10 @@ export interface BoardWindowConfig {
*/
reopenGapMinutes: number;
exportBookingLeadHours: number;
/** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */
importCloseOffsetMinutes?: number | null;
/** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */
exportCloseOffsetMinutes?: number | null;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
@@ -507,10 +544,15 @@ export function listConfigBookingWindows(
cfg: BoardWindowConfig,
anchorOpensAt?: Date | null,
): BoardWindow[] {
// Bookings shut at the cutoff (departure closeOffset), not departure. The
// window opens still key off the real departure below; only closes are capped
// here, so the board draws the exact windows the engine runs.
const cutoff = bookingCloseCutoff(departure, direction, cfg);
if (direction === 'EXPORT') {
const start =
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
return [boardWindowFromInterval(start, cutoff)];
}
const windows: BoardWindow[] = [];
@@ -527,29 +569,29 @@ export function listConfigBookingWindows(
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// The loop terminates naturally: every cycle advances opensAt by at least
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
// reach departure. maxCycles is a derived runaway backstop sized to the real
// span (first open → departure) over the smallest possible advance, so a
// reach the cutoff. maxCycles is a derived runaway backstop sized to the real
// span (first open → cutoff) over the smallest possible advance, so a
// legitimate config is never silently truncated — only a pathological
// zero-length one would hit it.
const spanMs = departure.getTime() - opensAt.getTime();
const spanMs = cutoff.getTime() - opensAt.getTime();
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
if (opensAt.getTime() >= cutoff.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff);
if (opensAt == null) break;
}
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
// Degenerate config (no window before the cutoff) — surface a single window
// clamped to the cutoff so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff));
}
return windows;
}

View File

@@ -137,4 +137,109 @@ describe('BookingBatchService — exportSpaceReport (whole-booking, single train
'No export train is accepting bookings for this day',
);
});
describe('dayImportAvailability (advisory, summed across the day)', () => {
const DAY_STR = '2026-07-20';
const importSchedule = (id: string, over: Record<string, unknown> = {}) => ({
id,
status: 'SCHEDULED',
direction: 'IMPORT',
scheduledDepartureDate: DAY,
bookingWindowStatus: 'OPEN',
windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase
...over,
});
// A bulk booking small enough to fit; freeWagons is what matters, not `fits`.
const importBooking = (cargoTons: number) =>
({
id: 'bk-imp',
freightType: 'BULK',
tradeDirection: 'IMPORT',
originYardId: 'yard-a',
destinationYardId: 'yard-b',
cargoTotalWeightVgm: cargoTons,
bookingContainers: [],
}) as unknown as Booking;
it('sums free wagons across every import train on the day', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1'),
importSchedule('train-2'),
]);
const one = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
// Re-run with a single train to prove two trains sum to double one train.
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1'),
]);
const solo = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
expect(solo.freeWagons).toBeGreaterThan(0);
expect(one.freeWagons).toBe(solo.freeWagons * 2);
expect(one.trainsForDay).toBe(true);
});
it('ignores EXPORT trains — they are not part of the import pool', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1'),
{ ...importSchedule('train-2'), direction: 'EXPORT' },
]);
const both = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1'),
]);
const solo = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
expect(both.freeWagons).toBe(solo.freeWagons);
});
it('ignores FULL trains', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1', { bookingWindowStatus: 'FULL' }),
]);
const report = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
expect(report.freeWagons).toBe(0);
expect(report.trainsForDay).toBe(false);
});
it('nets out capacity already held by reserved bookings', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
importSchedule('train-1'),
]);
const empty = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
bookingsRepository.findReservedForSchedule.mockResolvedValue([
heavyReserved,
]);
const withHold = await service.dayImportAvailability(
importBooking(60),
DAY_STR,
);
expect(withHold.freeWagons).toBeLessThan(empty.freeWagons);
});
});
});

View File

@@ -731,6 +731,64 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
* summed across every train on the booking's corridor that day. Unlike the
* export gate this does NOT block and does NOT first-fit a single train:
* import is batched and splittable, so the honest number a customer can plan
* against is the TOTAL room across the day's trains for the booking's wagon
* type, in that type's own wagon units.
*
* It deliberately skips the `isFillable` window-phase gate. A customer picks a
* shipment day while its window is still OPEN (or pre-window) — the batch fill
* only makes those trains fillable after the window closes — so gating on the
* fill phase here would report 0 for exactly the days customers are choosing.
* We therefore count any non-FULL train that carries the leg, netting out the
* capacity already consumed by allocated + live-reserved bookings
* (`remainingBudget`). The count is an upper bound: the batch engine may still
* split the booking across trains or defer a remainder to a later window.
*/
async dayImportAvailability(
booking: Booking,
day: string,
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.bookingWindowStatus !== 'FULL' &&
s.direction !== 'EXPORT',
);
const wagonDims = await this.loadWagonDims();
const dims = this.dimsFor(booking, wagonDims);
const need = this.wagonsFor(booking, wagonDims);
let freeWagons = 0;
let trainsForDay = false;
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
trainsForDay = true;
freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons;
}
return { freeWagons, need, trainsForDay };
}
/**
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
@@ -1118,6 +1176,17 @@ export class BookingBatchService implements OnModuleInit {
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
// Frozen close offsets: a snapshot null means "no offset for this train"
// and stays null (not the live offset); only legacy rows lacking the
// column (undefined) fall back to live config.
importCloseOffsetMinutes:
s.ruleImportCloseOffsetMinutes !== undefined
? s.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
s.ruleExportCloseOffsetMinutes !== undefined
? s.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(

View File

@@ -19,6 +19,17 @@ export interface BookingWindowConfig {
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
paymentWindowMinutes: number;
/**
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set
* (> 0), the effective booking cutoff is `departure this`, capping the first
* window close and every reopen cycle. NULL/0 ⇒ no offset (close at departure).
*/
importCloseOffsetMinutes?: number | null;
/**
* Minutes before departure the EXPORT FCFS booking window shuts. When set (> 0),
* export closes at `departure this` instead of at departure. NULL/0 ⇒ none.
*/
exportCloseOffsetMinutes?: number | null;
}
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */

View File

@@ -22,6 +22,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
eatDay,
nextCycleOpensAt,
@@ -415,6 +416,15 @@ export class BookingWindowService implements OnModuleInit {
// window and the cycle stays in PAYMENT; check live reservations on THIS
// schedule because the day-level fill may have reserved onto a sibling.
// Waiting bookings that fit no train stay pooled and the window reopens.
// Booking shuts at the configured cutoff (departure closeOffset), not
// departure — every phase-end below is bounded by it, mirroring the initial
// window computation.
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
cfg,
);
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
if (
promoted > 0 &&
@@ -423,8 +433,8 @@ export class BookingWindowService implements OnModuleInit {
let paymentPhaseEndsAt = new Date(
now.getTime() + cfg.paymentWindowMinutes * 60_000,
);
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
if (paymentPhaseEndsAt > cutoff) {
paymentPhaseEndsAt = cutoff;
}
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
this.logger.log(
@@ -441,11 +451,7 @@ export class BookingWindowService implements OnModuleInit {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const nextOpensAt = nextCycleOpensAt(
now,
officeHours,
schedule.scheduledDepartureDate,
);
const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff);
if (nextOpensAt == null) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
this.logger.log(
@@ -464,8 +470,8 @@ export class BookingWindowService implements OnModuleInit {
// Office hours end a running window early: never let the duration outlive
// the desk close (open 16:00, 3h, desk 817 → closes 17:00).
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
if (nextClosesAt > cutoff) {
nextClosesAt = cutoff;
}
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
// whether that is later today or next morning after the office-hours break.

View File

@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsNumber,
@@ -61,4 +62,15 @@ export class CreateContainerTrainScheduleDto {
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({
description:
'Reverse the wagon order on this train: the physically-last wagon becomes ' +
'position 1. Frozen on the schedule; applied every time the wagon plan is ' +
'rebuilt so the stored train order and the schedule order stay in sync.',
default: false,
})
@IsOptional()
@IsBoolean()
reverseWagonOrder?: boolean;
}

View File

@@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsNumber,
@@ -58,4 +59,15 @@ export class PreviewTrainScheduleDto {
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({
description:
'Reverse the wagon order on the train: the physically-last wagon becomes ' +
'position 1. The composition and allocations are unchanged — only the order ' +
'flips, applied at build so the stored train and schedule stay in sync.',
default: false,
})
@IsOptional()
@IsBoolean()
reverseWagonOrder?: boolean;
}

View File

@@ -67,4 +67,31 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
// Booking-close offsets: minutes before departure the window shuts. The UI
// enters days/hours/minutes and converts to minutes. 0 or null clears the
// offset (close at departure). Nullable so it can be explicitly cleared.
@ApiPropertyOptional({
example: 180,
nullable: true,
description:
'Minutes before departure the IMPORT booking window closes; 0/null = close at departure',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importCloseOffsetMinutes?: number | null;
@ApiPropertyOptional({
example: 1440,
nullable: true,
description:
'Minutes before departure the EXPORT booking window closes; 0/null = close at departure',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
exportCloseOffsetMinutes?: number | null;
}

View File

@@ -79,4 +79,21 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
/**
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set,
* the window's close (first cycle and every reopen) is capped at
* `departure this`, instead of the default open+duration/departure cap.
* NULL or 0 = no offset (previous behaviour).
*/
@Column({ name: 'import_close_offset_minutes', type: 'int', nullable: true })
importCloseOffsetMinutes?: number | null;
/**
* Minutes before departure the EXPORT FCFS booking window shuts. When set, the
* export window closes at `departure this` instead of at departure. NULL or
* 0 = no offset (export closes at departure, previous behaviour).
*/
@Column({ name: 'export_close_offset_minutes', type: 'int', nullable: true })
exportCloseOffsetMinutes?: number | null;
}

View File

@@ -234,13 +234,17 @@ describe('TrainSchedulingService', () => {
destinationStationId: 'yard-destination',
});
// Availability rows now report what the BOUNDED plan actually uses per
// type (never more than stock, so no shortfall on the rows themselves);
// the shortage is carried by the deferred bookings' own shortage rows.
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
expect(
result.fleetAvailability?.every((row) => row.needed <= row.available),
).toBe(true);
expect(result.deferredBookings?.length).toBeGreaterThan(0);
expect(result.deferredBookings?.[0]?.reason).toContain('short');
expect(result.summary.wagonsNeeded).toBeLessThan(30);
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
true,
);
expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true);
});
it('computes slot-based preview for Group A', async () => {

View File

@@ -109,12 +109,13 @@ import {
type FleetAvailabilityRow,
} from './fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
unboundedStock,
type AllowedWagonTypeMap,
type WagonStock,
} from './wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
getContainerSlotSequenceNos,
roundTons,
@@ -134,8 +135,10 @@ import {
WagonTypeDimensions,
} from './train-capacity.util';
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
} from './booking-batch.constants';
@@ -180,6 +183,8 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
};
}
@@ -204,6 +209,8 @@ export function effectiveWindowConfig(
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
ruleImportCloseOffsetMinutes?: number | null;
ruleExportCloseOffsetMinutes?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
@@ -220,6 +227,18 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
// The close offset is frozen per-schedule: a snapshot value of null means
// "created with no offset" and must NOT inherit a later live offset (that
// would retro-shrink an open train's window). Only a truly legacy row that
// predates the snapshot column (value undefined) falls back to live config.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
}
@@ -588,7 +607,11 @@ export class TrainSchedulingService {
trainScheduleId: query.trainScheduleId,
day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
const tareDims = await this.loadWagonTareDims();
return {
count: bookings.length,
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
};
}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
@@ -633,6 +656,11 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
// Store 0 as null so "no offset" is a single canonical value.
if (dto.importCloseOffsetMinutes !== undefined)
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
if (dto.exportCloseOffsetMinutes !== undefined)
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
@@ -649,7 +677,9 @@ export class TrainSchedulingService {
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null;
dto.exportBookingLeadHours != null ||
dto.importCloseOffsetMinutes !== undefined ||
dto.exportCloseOffsetMinutes !== undefined;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -721,6 +751,17 @@ export class TrainSchedulingService {
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
// A per-schedule override isn't a close-offset control, so inherit the
// offset already frozen on the schedule (null = none), or the live one for
// legacy rows — the override must not silently drop the global offset.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -1054,6 +1095,13 @@ export class TrainSchedulingService {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
// Offsets are optional: a missing/unset value means "no offset", not a
// numeric default — keep it null so bookingCloseCutoff falls back to
// departure. Zero and negatives are treated as "no offset" too.
const offset = (v: unknown): number | null => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
};
return {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
@@ -1062,6 +1110,8 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
};
}
@@ -1322,6 +1372,7 @@ export class TrainSchedulingService {
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
...windowFields,
}),
);
@@ -1400,6 +1451,10 @@ export class TrainSchedulingService {
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
// created — every (re)assignment rebuilds the plan under the same flag so the
// stored train order stays consistent no matter how bookings are added.
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
@@ -1476,6 +1531,31 @@ export class TrainSchedulingService {
});
}
// Every REQUESTED booking must have made the plan. Silently dropping a
// deferred one let the workspace "Add from pool" report success while the
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
// 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.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(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`,
);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
@@ -1817,13 +1897,15 @@ export class TrainSchedulingService {
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
const tareDims = await this.loadWagonTareDims();
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
weightTons: b.cargoTotalWeightVgm,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(b, tareDims),
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
@@ -3668,13 +3750,6 @@ export class TrainSchedulingService {
const allowed = await this.loadAllowedWagonTypes(bookings);
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
// Pure demand (unbounded stock) drives the availability report rows.
const demandPlan = planWagonsWithStock({
bookings,
allowed,
stock: unboundedStock(allowed),
}).plan;
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
@@ -3711,10 +3786,24 @@ export class TrainSchedulingService {
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
const wagonPlan = planned.plan;
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
// the stored train order, allocations and snapshot all carry the reversed
// order together. No-op unless the schedule set the flag.
const wagonPlan = applyWagonOrderReversal(
planned.plan,
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
);
// Availability rows come from the BOUNDED plan — the one that actually
// mixes wagon types against real stock. The old unbounded "pure demand"
// plan had infinite stock of every allowed type, so its tie-break parked a
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
// the deferred bookings' own shortage rows.
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
demandPlan,
planned.plan,
stock.remainingByTypeId,
stock.codesByTypeId,
);
@@ -4805,7 +4894,10 @@ export class TrainSchedulingService {
);
}
private mapEligibleBooking(booking: Booking) {
private mapEligibleBooking(
booking: Booking,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
) {
return {
id: booking.id,
reference: booking.reference,
@@ -4819,7 +4911,8 @@ export class TrainSchedulingService {
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
quantity:
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
weightTons: roundTons(booking.cargoTotalWeightVgm),
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(booking, tareDims),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
@@ -6110,6 +6203,85 @@ export class TrainSchedulingService {
}
}
/**
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
t.id,
{
tareWeightTons: Number(t.tareWeightTons) || 0,
capacityTons: Number(t.capacityTons) || 0,
},
]),
);
return {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
},
container: {
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
}
/**
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
* every wagon the booking occupies — the same gross axis the batch engine
* spends against the locomotive's pull limit. Wagon count mirrors the batch
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
* tons ÷ payload for bulk — whichever is largest).
*/
private grossBookingWeightTons(
booking: Pick<
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
const wagonTypeId =
booking.freightType === 'BULK'
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
const dims = {
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
capacityTons: typed?.capacityTons || fallback.capacityTons,
};
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight =
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
const wagons = Math.max(1, stored, byLength, byWeight);
return roundTons(cargo + wagons * dims.tareWeightTons);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
@@ -6118,6 +6290,9 @@ export class TrainSchedulingService {
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
// locomotive actually hauls and the axis its pull limit is compared against.
const tareDims = await this.loadWagonTareDims();
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
@@ -6406,7 +6581,9 @@ export class TrainSchedulingService {
id: sb.booking?.id ?? sb.bookingId,
reference: sb.booking?.reference ?? null,
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
weightTons: sb.booking
? this.grossBookingWeightTons(sb.booking, tareDims)
: 0,
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,

View File

@@ -1,6 +1,10 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { planWagonsWithStock } from './wagon-plan-flex.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
} from './wagon-plan-flex.util';
import type { WagonPlanSlot } from './wagon-plan.util';
const nw6: WagonType = {
id: 'wt-nw6',
@@ -130,3 +134,56 @@ describe('planWagonsWithStock — shortage detail', () => {
expect(result.deferred[0]?.shortage).toBeNull();
});
});
describe('applyWagonOrderReversal', () => {
const slot = (
seq: number,
wagonTypeId: string,
bookingId: string,
): WagonPlanSlot =>
({
sequenceNo: seq,
wagonTypeId,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 25,
allocations: [{ bookingId }],
}) as unknown as WagonPlanSlot;
const plan: WagonPlanSlot[] = [
slot(1, 'wt-a', 'BKG-A'),
slot(2, 'wt-b', 'BKG-B'),
slot(3, 'wt-c', 'BKG-C'),
];
it('returns the plan unchanged when the flag is false/absent', () => {
expect(applyWagonOrderReversal(plan, false)).toBe(plan);
expect(applyWagonOrderReversal(plan, undefined)).toBe(plan);
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (was seq 3, wt-c) is now position 1.
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
expect(reversed[0].sequenceNo).toBe(1);
expect(
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
expect(
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
});
});

View File

@@ -340,6 +340,31 @@ export function planWagonsWithStock(params: {
};
}
/**
* Reverse the wagon ORDER of a built plan when a schedule opts in.
*
* The plan comes out of planWagonsWithStock ordered by booking scheduling order
* (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last
* wagon becomes wagon #1: the slot objects — and the bookings already allocated
* into each — travel WITH their slot, so only the position numbers flip. The
* physical composition, which booking is in which wagon, and every per-slot
* field are untouched; sequenceNo is renumbered 1..N over the reversed array.
*
* This single flip is the whole feature: persistTrainSetWagons writes these
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
* read them — so the stored train order and the schedule order stay identical,
* just reversed. A false/absent flag returns the plan unchanged.
*/
export function applyWagonOrderReversal(
plan: WagonPlanSlot[],
reverse: boolean | null | undefined,
): WagonPlanSlot[] {
if (!reverse) return plan;
return [...plan]
.reverse()
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
}
/** Unbounded stock — used to compute pure demand for availability reporting. */
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
const remainingByTypeId = new Map<string, number>();