mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
train
This commit is contained in:
@@ -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' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableTrainsQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user