This commit is contained in:
Marshal
2026-07-14 11:06:49 +00:00
parent 957a185a4d
commit 6d0cf50b4d
64 changed files with 4896 additions and 404 deletions

View File

@@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => {
};
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } });
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(30);
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
@@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => {
{
quantity: 2,
wagonsRequired: 2,
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' },
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
},
],
};

View File

@@ -2913,21 +2913,23 @@ export class BookingBatchService implements OnModuleInit {
/**
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
* cargo type's wagon_type_id, container through the first container line's
* type — the same FK resolution `resolveWagonType` applies when the paid
* booking is allocated. Board/fill math measured on a representative wagon
* while allocation validated the real one let a selected batch flunk the
* post-payment gross-weight check; sharing the resolution closes that gap.
* Falls back to the representative dims when the FK or relation is absent.
* cargo type's allowed wagon-type list, container through the first container
* line's — the same list resolution the scheduling planner applies when the
* paid booking is allocated. Board/fill math measured on a representative
* wagon while allocation validated the real one let a selected batch flunk
* the post-payment gross-weight check; sharing the resolution closes that
* gap. Uses the first configured type (the fill engine has no train context);
* falls back to the representative dims when the list or relation is absent.
*/
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const wagonTypeId =
booking.freightType === "BULK"
? booking.cargoType?.wagonTypeId
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.map((line) => line.containerType?.wagonTypeId)
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
if (!dims) return fallback;

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AvailableTrainsQueryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
}

View File

@@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({
@ApiPropertyOptional({
format: 'uuid',
description:
'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.',
})
@IsOptional()
@IsUUID()
trainId?: string;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
locomotiveIds?: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
@@ -153,6 +154,18 @@ export class TrainSchedulingController {
);
}
@Get("available-trains")
@TrainSchedulingView()
@ApiOperation({
summary:
"List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs",
})
getAvailableTrains(@Query() query: AvailableTrainsQueryDto) {
return this.trainSchedulingService.getAvailableTrainsForRoute(
query.routeId,
);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -72,7 +72,7 @@ const makeBooking = (
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id },
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
},
],
...extra,
@@ -80,7 +80,7 @@ const makeBooking = (
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
@@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => {
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => {
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
query: jest.fn().mockResolvedValue([]),
};
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
@@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary.wagonsNeeded).toBe(45);
expect(result.wagonPlan).toHaveLength(45);
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
// the planner packs by container size, not the stored per-line fallback.
expect(result.summary.wagonsNeeded).toBe(40);
expect(result.wagonPlan).toHaveLength(40);
});
it('returns soft hold warnings without forceAssign', async () => {
@@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => {
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id },
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
},
],
}),
@@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => {
destinationStationId: 'yard-djibouti',
});
expect(result.valid).toBe(false);
expect(
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
).toBe(true);
// List-based planner: a booking with no plannable wagon at the yard is
// DEFERRED with the wagon-type reason (assign still hard-fails when no
// booking fits), instead of surfacing a phantom-slot violation.
expect(result.valid).toBe(true);
expect(result.wagonPlan).toHaveLength(0);
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
});
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {

View File

@@ -0,0 +1,299 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
expandBookingContainerUnits,
roundTons,
tareTonsOf,
teuSlotsForSizeFt,
type SlotLoadType,
type WagonPlanSlot,
} from './wagon-plan.util';
/**
* Wagon types allowed to carry each container type / bulk cargo type — the
* many-to-many configuration lists, resolved once per validation run.
*/
export type AllowedWagonTypeMap = {
byContainerTypeId: Map<string, WagonType[]>;
byCargoTypeId: Map<string, WagonType[]>;
};
/**
* Plannable wagon inventory. TRAIN mode is the built train's own consist —
* a hard cap, the plan never reaches for loose yard wagons. YARD mode is the
* AVAILABLE pool at the boarding yards (legacy schedules).
*/
export type WagonStock = {
mode: 'TRAIN' | 'YARD';
/** Remaining plannable wagons per wagon type id. Missing type = 0. */
remainingByTypeId: Map<string, number>;
/** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map<string, string>;
};
export type FlexPlanResult = {
plan: WagonPlanSlot[];
fitting: Booking[];
deferred: DeferredBookingRow[];
/**
* Misconfiguration (a scheduled type with no wagon types configured) —
* a hard violation, unlike stock shortfalls which merely defer bookings.
*/
configIssues: string[];
};
type OpenSlot = {
slot: WagonPlanSlot;
teuUsed: number;
kind: SlotLoadType;
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
cargoTypeId: string | null;
freeCapacityTons: number;
};
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
sequenceNo: 0, // stamped at the end
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: Number(wagonType.capacityTons),
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
slotLoadType: kind,
});
const addAllocation = (
slot: WagonPlanSlot,
bookingId: string,
bookingReference: string,
weightTons: number,
loadType: AllocationLoadType,
) => {
let allocation = slot.allocations.find((a) => a.bookingId === bookingId);
if (!allocation) {
allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType };
slot.allocations.push(allocation);
}
allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons);
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons);
};
/**
* Build the wagon plan against a wagon-type inventory, mixing wagon types
* within one consist. Each booking is atomic: it either fits entirely (its
* containers/tonnage placed on wagons whose type is allowed for its container
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
* with a different cargo type.
*/
export function planWagonsWithStock(params: {
bookings: Booking[];
allowed: AllowedWagonTypeMap;
stock: WagonStock;
}): FlexPlanResult {
const { bookings, allowed, stock } = params;
const remaining = new Map(stock.remainingByTypeId);
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>();
const noStockMessage = (candidates: WagonType[]): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
};
/** Open a new wagon of one of the candidate types, consuming stock. */
const openSlot = (
candidates: WagonType[],
kind: SlotLoadType,
cargoTypeId: string | null,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
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),
)[0];
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
};
openSlots.push(open);
return open;
};
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
if (booking.freightType === 'CONTAINER') {
const units = expandBookingContainerUnits([booking]);
if (!units.length) {
// Degenerate container booking with no lines still reserves one wagon
// (legacy behavior) — but there is no container type to resolve against.
return {
kind: 'config',
message: `Booking ${booking.reference} has no container lines to plan`,
};
}
for (const unit of units) {
const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? [];
if (!candidates.length) {
return {
kind: 'config',
message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`,
};
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
if (!target) {
const openedSlot = openSlot(candidates, 'CONTAINER', null);
if ('message' in openedSlot) return openedSlot;
target = openedSlot;
}
addAllocation(
target.slot,
unit.bookingId,
unit.bookingReference,
unit.grossWeightTons,
AllocationLoadType.Container,
);
target.teuUsed += teu;
}
return null;
}
// BULK — weight-based, one cargo type per wagon.
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null;
const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
if (!candidates.length) {
return {
kind: 'config',
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
};
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
let placedAnywhere = false;
// Top off wagons already carrying THIS cargo type before opening new ones.
for (const open of openSlots) {
if (remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
addAllocation(
open.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
while (remainingWeight > 0 || !placedAnywhere) {
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
if ('message' in openedSlot) return openedSlot;
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
addAllocation(
openedSlot.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
return null;
};
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 slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
freeCapacityTons: open.freeCapacityTons,
assignedWeightTons: open.slot.assignedWeightTons,
allocationCount: open.slot.allocations.length,
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
}));
const problem = tryPlaceBooking(booking);
if (!problem) {
fitting.push(booking);
continue;
}
// Roll back this booking's partial placements.
remaining.clear();
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
openSlots.length = slotCountSnapshot;
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
if (!snap) return;
open.teuUsed = snap.teuUsed;
open.freeCapacityTons = snap.freeCapacityTons;
open.slot.assignedWeightTons = snap.assignedWeightTons;
open.slot.allocations.length = snap.allocationCount;
snap.allocationWeights.forEach((weight, allocationIndex) => {
open.slot.allocations[allocationIndex].allocatedWeightTons = weight;
});
});
if (problem.kind === 'config') configIssues.add(problem.message);
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
}
return {
plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })),
fitting,
deferred,
configIssues: [...configIssues],
};
}
/** Unbounded stock — used to compute pure demand for availability reporting. */
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const list of [
...allowed.byContainerTypeId.values(),
...allowed.byCargoTypeId.values(),
]) {
for (const wagonType of list) {
remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER);
codesByTypeId.set(wagonType.id, wagonType.code);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}

View File

@@ -514,7 +514,7 @@ export function validateTrainLimits(
*/
export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: WagonType[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;