From bedcdca78be4c0b791c8c10f563b77ee4c2c1cea Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 04:24:08 +0000 Subject: [PATCH 1/2] Enforce non-negative values for numeric inputs across various components --- .../train-scheduling.service.ts | 118 ++---------------- .../ruleEngine/RuleEngineFormDialog.tsx | 14 ++- .../BookingWindowSettingsModal.tsx | 2 + .../trainScheduling/DurationField.tsx | 1 + .../TrainSchedulingGlobalRulesPage.tsx | 7 ++ .../new-booking-form/step5-cargo-details.tsx | 15 ++- .../src/pages/contracts/NewShipmentPage.tsx | 19 ++- .../contracts/new-shipment-form/schema.ts | 35 +++++- 8 files changed, 92 insertions(+), 119 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f858c6c14..79b009d79 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; +import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -3446,37 +3444,6 @@ export class TrainSchedulingService { return wagonType; } - /** - * Soft wagon-type resolution for the customer-facing availability preview - * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; - * returns null (→ "no days") instead of throwing when nothing is configured, - * since this only estimates which days have wagons and creates no booking. - */ - private async resolveWagonTypeForPreview( - freightType: 'CONTAINER' | 'BULK', - cargoTypeCode: string | null, - ): Promise { - if (freightType === 'BULK') { - if (!cargoTypeCode) return null; - const cargoType = await this.dataSource.getRepository(CargoType).findOne({ - where: { code: cargoTypeCode }, - relations: { wagonType: true }, - }); - return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; - } - - // Container preview: the input carries no specific container type, so use the - // wagon type of the first configured (active) container type. - const containerType = await this.dataSource - .getRepository(ContainerType) - .findOne({ - where: { isActive: true, wagonTypeId: Not(IsNull()) }, - relations: { wagonType: true }, - order: { displayOrder: 'ASC' }, - }); - return containerType?.wagonType?.isActive ? containerType.wagonType : null; - } - /** * Stamp each plan slot with the leg it occupies (dynamic consist): the * boarding/alighting yards of the bookings it carries. Null means the @@ -4237,13 +4204,14 @@ export class TrainSchedulingService { } /** - * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given - * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day - * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that - * schedule's origin yard, and (b) remaining train capacity (not fully - * allocated). Days with trains but not enough matching wagons are excluded. - * Same `{ days: string[] }` shape as getAvailableDays — the customer still - * picks a DAY, not a train. + * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day + * is selectable when ≥1 OPEN schedule on the route that day still has remaining + * train capacity (not fully allocated). Wagon availability is deliberately NOT + * checked here: whether a matching wagon currently sits in the right yard is an + * operational question staff resolve when they approve or reject the booking, + * not something the customer can act on while choosing a date. Same + * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, + * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; @@ -4259,85 +4227,17 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - // Resolve the wagon type this cargo needs via the cargo/container-type FK. - // Soft (customer availability preview): no days if unresolved, never throws. - const requiredType = await this.resolveWagonTypeForPreview( - input.freightType, - input.cargoTypeCode ?? null, - ); - if (!requiredType) return { days: [] }; - - // How many wagons of that type the cargo needs. - const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); - void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - - // TEMP (per request): wagon-availability filtering is DISABLED. A day is now - // offered whenever a bookable schedule that day has remaining train capacity - // — regardless of whether matching wagons are actually available at the - // origin / boarding yard. This surfaces days even when no wagon is on hand. - // Restore the block below to bring back the "enough matching wagons" gate. - // - // // AVAILABLE wagons of the required type, counted once per origin yard. - // const availableByYard = new Map(); - // const availableAt = async (yardId: string): Promise => { - // const cached = availableByYard.get(yardId); - // if (cached !== undefined) return cached; - // const counts = await this.countFleetAvailability(yardId); - // const n = - // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - // availableByYard.set(yardId, n); - // return n; - // }; - const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - // TEMP (per request): wagon-availability check commented out — see note - // above. Dynamic consist: wagons may ride from the train's origin OR - // already sit at the booking's own boarding yard and attach when the train - // arrives — either pool can serve a sub-corridor booking. - // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - // if ( - // !enoughWagons && - // input.originYardId && - // input.originYardId !== s.originStationId - // ) { - // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; - // } - // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } - /** - * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / - * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per - * wagon. Mirrors wagon-plan.util without fabricating Booking entities. - */ - private wagonsNeededForCargo( - input: { - freightType: 'CONTAINER' | 'BULK'; - totalWeightTons?: number; - containers?: Array<{ containerSize: string; quantity: number }>; - }, - wagonType: WagonType, - ): number { - if (input.freightType === 'BULK') { - const capacity = Number(wagonType.capacityTons) || 1; - const weight = Number(input.totalWeightTons ?? 0); - return Math.max(1, Math.ceil(weight / capacity)); - } - const teu = (input.containers ?? []).reduce((sum, c) => { - const per = c.containerSize === '40ft' ? 2 : 1; - return sum + per * Math.max(0, Number(c.quantity ?? 0)); - }, 0); - return Math.max(1, Math.ceil(teu / 2)); - } - /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8553bcf0b..403a98f1b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({ ); } + const isNumber = field.type === "number"; + return ( setField(field.name, e.currentTarget.value)} + onChange={(e) => { + const next = e.currentTarget.value; + if (isNumber && next.trim().startsWith("-")) return; + setField(field.name, next); + }} placeholder={field.placeholder} required={field.required} size="md" diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index ec3774ce6..1bfa392bc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({ } min={1} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> ) : ( @@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({ } min={0} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx index 06c9013d4..9c3c54f3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -98,6 +98,7 @@ export default function DurationField({ emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" + allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index c71235640..0186d5acb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainWeightTons: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0.001} disabled={loading} @@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} disabled={loading} @@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowOpenHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} @@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowCloseHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index ac336c47c..b582468cf 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react"; import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core"; @@ -26,6 +26,15 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * Every quantity on this step is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + /** * One numbered toggle per container unit in the line — tap units to mark how * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection @@ -379,6 +388,7 @@ export function Step5CargoDetails({ }} id="cargoWeight" type="number" + onKeyDown={blockNegative} label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"} placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"} leftSection={ @@ -435,6 +445,7 @@ export function Step5CargoDetails({ render={({ field: hq, fieldState }) => ( ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 77086103b..c36a74255 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -58,6 +58,15 @@ type ShipmentForm = ReturnType< typeof useForm >; +/** + * Every quantity on this form is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + export default function NewShipmentPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -962,6 +971,7 @@ function CargoStep({ qty) { + if (h < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "hazardousQuantity"], + message: "Enter a valid hazardous quantity.", + }); + } else if (h > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "hazardousQuantity"], @@ -120,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } if (ctx.isReefer) { const r = Number(line.reeferQuantity || 0); - if (r > qty) { + if (r < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "reeferQuantity"], + message: "Enter a valid refrigerated quantity.", + }); + } else if (r > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "reeferQuantity"], @@ -130,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } }); } else { - const bulkCap = - ctx.unitOfMeasure === "PER_ITEM" - ? Number(data.itemCount || 0) - : Number(data.cargoWeightTons || 0); + const isPerItem = ctx.unitOfMeasure === "PER_ITEM"; + const bulkCap = isPerItem + ? Number(data.itemCount || 0) + : Number(data.cargoWeightTons || 0); + + // The bulk cargo amount itself: a positive magnitude. Without this a + // negative (typed past the input's `min`) reaches the API unchecked. + const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons"; + if (Number.isNaN(bulkCap) || bulkCap <= 0) { + refineCtx.addIssue({ + code: "custom", + path: [bulkPath], + message: "Enter a quantity greater than 0.", + }); + } const boundBulkPortion = ( on: boolean, From 1b2b3f68f86495f73779559b0534306b355f8000 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 06:05:34 +0000 Subject: [PATCH 2/2] Add tareWeightTons property to CreateWagonTypeDto and update related components --- ...0000000-MakeWagonTypeTareWeightRequired.ts | 63 +++ .../booking-batch.constants.ts | 10 + .../booking-batch.service.spec.ts | 49 +++ .../train-scheduling/booking-batch.service.ts | 372 +++++++++++++----- .../booking-window.service.ts | 12 + .../train-capacity.util.spec.ts | 209 +++++++--- .../train-scheduling/train-capacity.util.ts | 237 ++++++++--- .../train-scheduling.service.ts | 30 +- .../train-scheduling/wagon-plan.util.ts | 64 +-- .../wagon-types/dto/create-wagon-type.dto.ts | 11 + .../wagon-types/entities/wagon-type.entity.ts | 5 +- .../wagon-types/wagon-types.service.ts | 1 + .../src/pages/fleet/FleetCrudPages.tsx | 46 ++- .../src/pages/fleet/config/resources.ts | 9 + .../pages/trainScheduling/BatchBoardPage.tsx | 106 +++-- .../BatchScheduleDetailPage.tsx | 7 +- .../src/services/locomotives.service.ts | 4 + .../src/services/wagon-types.service.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 5 +- 19 files changed, 979 insertions(+), 263 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts diff --git a/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts new file mode 100644 index 000000000..ff85f3f24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Tare weight becomes mandatory on a wagon type. + * + * The locomotive's pull limit is a GROSS limit — it drags the wagon as well as + * the cargo — so capacity math cannot run without a tare. A NULL tare silently + * read as zero and let trains overbook by the tare fraction (~27% on a PW2 + * consist), so the column is now NOT NULL. + * + * Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which + * upserts the ten real EDR types). Backfill those by code first, and give any + * remaining custom/demo type the NW5 flat-wagon tare rather than fail the + * migration — a wrong-but-plausible tare is recoverable in the admin UI; a + * blocked deploy is not. + */ +export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface { + private readonly tareByCode: Array<[string, number]> = [ + ['NW7', 37.1], + ['NW5', 22.4], + ['PW2', 25.2], + ['GW2', 23], + ['CW4', 24.8], + ['CW3', 23.4], + ['KW2', 25.2], + ['KW3', 24], + ['NW6', 25.3], + ['BW1', 32.1], + ]; + + /** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */ + private readonly fallbackTareTons = 22.4; + + public async up(queryRunner: QueryRunner): Promise { + for (const [code, tareWeightTons] of this.tareByCode) { + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $2 + WHERE code = $1 AND tare_weight_tons IS NULL;`, + [code, tareWeightTons], + ); + } + + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $1 + WHERE tare_weight_tons IS NULL;`, + [this.fallbackTareTons], + ); + + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons SET NOT NULL;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons DROP NOT NULL;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index d8dc6b116..1ff1880b7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -24,3 +24,13 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14; /** Default CW3 covered wagon length for bulk bookings (m). */ export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14; + +/** + * Fallback tare weights (T) matching the length fallbacks above. The locomotive + * pull limit is a GROSS limit, so a booking's weight budget must include the + * empty weight of every wagon it occupies — not just its cargo. + */ +export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; + +/** Default CW3 gondola tare for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 6abd56b9f..6f59ca123 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.Mock; }; let trainSchedulesRepository: { + findById: jest.Mock; findByIdWithFullGraph: jest.Mock; findAll: jest.Mock; }; @@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue({ + id: scheduleId, + bookingWindowStatus: 'OPEN', + windowPhase: null, + }), findByIdWithFullGraph: jest.fn().mockResolvedValue({ id: scheduleId, maxWagons: 10, @@ -407,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => { // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); + + it('clears a stale FULL flag and fills a train whose bookings all expired', async () => { + // The deadlock: train A filled once, every booking then expired, but + // bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever + // reads the budget, so the batch skipped the train forever — it just cycled + // PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd + // already-pinned booking got settled, one per cycle. + const staleFull = { + id: trainA, + maxWagons: 1, + bookingWindowStatus: 'FULL', + // The batch runs while the customer window is closed. + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainA}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]); + // Live capacity says the train is empty: 1 free wagon, nothing allocated. + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull); + // refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase); + // the re-read reports it, and isFillable() admits CLOSED during PAYMENT. + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainA, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ + commercial('waiting', 30), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + // The train was reopened to the batch and actually filled, not skipped. + expect(touched).toEqual([trainA]); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); }); describe('expireUnacceptedForRouteDay — doc-review sweep', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index afe284206..f0ac6f4a4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -30,12 +30,17 @@ import { BillingService } from "../billing/billing.service"; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { + WagonTypeDimensions, + bookingGrossWeightTons, bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, + trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; @@ -63,7 +68,14 @@ interface RouteDayGroup { day: string; } -type WagonLengths = { container: number; bulk: number }; +/** + * Per-freight-type wagon dimensions used to size a booking's capacity draw: + * its length on the train and the tare it adds to the locomotive's gross load. + */ +type WagonDims = { + container: { lengthMeters: number; tareWeightTons: number }; + bulk: { lengthMeters: number; tareWeightTons: number }; +}; export type BatchBoardBookingState = | "ALLOCATED" @@ -501,8 +513,8 @@ export class BookingBatchService implements OnModuleInit { } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const required = need ?? this.needFor(booking, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -511,7 +523,7 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + 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 corridorMatched = true; @@ -549,8 +561,8 @@ export class BookingBatchService implements OnModuleInit { if (!partner || partner.status !== 'FULLY_EXECUTED') { return; } - const wagonLengths = await this.loadWagonLengths(); - const need = this.combinedNeed(booking, partner, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const need = this.combinedNeed(booking, partner, wagonDims); const scheduleId = await this.pickExportSchedule(booking, need); await this.reserveOnExport([booking, partner], scheduleId); } @@ -620,7 +632,8 @@ export class BookingBatchService implements OnModuleInit { order: { scheduledDepartureDate: "ASC" }, }); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; @@ -635,7 +648,7 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const items: BatchBoardBooking[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); return { id: b.id, reference: b.reference ?? b.id.slice(0, 8), @@ -655,7 +668,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items)); + board.push(this.buildScheduleSummary(s, items, rules)); } return board; } @@ -678,7 +691,8 @@ export class BookingBatchService implements OnModuleInit { ); } - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -724,7 +738,7 @@ export class BookingBatchService implements OnModuleInit { } const items: BatchBoardBookingDetail[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { id: b.id, @@ -871,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -902,6 +916,13 @@ export class BookingBatchService implements OnModuleInit { return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** + * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already + * includes the tare of the wagons it occupies), so the ceiling it is measured + * against must be the same one the fill loop spends from: the locomotive floored + * by the global rule caps and widened by its overage tolerance. Reading the raw + * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + */ private computeBoardCapacity( items: Array<{ state: BatchBoardBookingState; @@ -911,22 +932,40 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); + const caps = loco + ? trainHardCaps( + { + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }, + { + maxTrainWeightTons: rules?.maxTrainWeightTons + ? Number(rules.maxTrainWeightTons) + : undefined, + maxTrainLengthMeters: rules?.maxTrainLengthMeters + ? Number(rules.maxTrainLengthMeters) + : undefined, + }, + ) + : null; + const round2 = (value: number) => Math.round(value * 100) / 100; + return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: - Math.round( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, - ) / 100, - maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: - Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / - 100, - maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + allocatedLengthMeters: round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ), + maxLengthMeters: caps ? caps.maxLengthMeters : null, + usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, }; } @@ -934,6 +973,7 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -966,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1036,10 +1076,10 @@ export class BookingBatchService implements OnModuleInit { } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); if (budget.maxRemaining().wagons <= 0) { await this.setWindow(scheduleId, "FULL"); return 0; @@ -1064,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. @@ -1084,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, budget, - wagonLengths, + wagonDims, ); if (!freed) continue; // still doesn't fit even after preempt } else { @@ -1151,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { + const { scheduleIds } = await this.fillRouteDayInternal( + originYardId, + destinationYardId, + day, + ); + return scheduleIds; + } + + /** + * Route-day top-up for a single schedule: re-run the DAY pool over the whole + * corridor the schedule belongs to, and report how many commercial units got a + * fresh pay window. + * + * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on + * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a + * booking that has not been reserved yet has a NULL `train_schedule_id` — it is + * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows + * and the waiting list never boarded after an expiry freed capacity; bookings + * trickled in one per window cycle instead. + */ + private async topUpFill(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const { commercialReserved } = await this.fillRouteDayInternal( + schedule.originStationId, + schedule.destinationStationId, + eatDay(schedule.scheduledDepartureDate), + ); + return commercialReserved; + } + + private async fillRouteDayInternal( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise<{ scheduleIds: string[]; commercialReserved: number }> { // The day's fillable schedules on this exact corridor, earliest first. Fillable // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — // the batch must run while the customer window is closed. @@ -1168,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit { }, ], }); - const scheduleIds = corridor + const onDay = corridor .filter( (s) => s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + eatDay(s.scheduledDepartureDate) === day, ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), - ) - .map((s) => s.id); + ); - if (scheduleIds.length === 0) return []; + // A schedule flagged FULL is rejected by isFillable() before its budget is + // ever consulted. Re-derive that flag from live capacity first, so a train + // whose bookings all expired is not skipped forever with an empty consist. + for (const s of onDay) { + if (s.bookingWindowStatus === "FULL") { + await this.refreshWindowStatus(s.id); + const fresh = await this.trainSchedulesRepository.findById(s.id); + if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus; + } + } + + const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id); + + if (scheduleIds.length === 0) { + return { scheduleIds: [], commercialReserved: 0 }; + } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; @@ -1200,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit { } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } - if (trains.length === 0) return []; + if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; // The day pool covers every booking whose leg lies somewhere on one of the // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an @@ -1225,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit { `poolSize=${pool.length} units=${units.length}`, ); let reservedThisPass = 0; + let commercialReserved = 0; for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => @@ -1267,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, t.budget, - wagonLengths, + wagonDims, ); if (freed) { target = t; @@ -1284,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit { // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. const offered = await this.maybeOfferPartial(booking, isPair, trains, need); - if (offered) continue; + if (offered) { + // A partial offer opens a real commercial pay window, same as reserve(). + commercialReserved += 1; + reservedThisPass += 1; + continue; + } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1305,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, target.id); if (partner) await this.reserve(partner, target.id); target.armed = true; + commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); reservedThisPass += 1; @@ -1326,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(t.id); } - return trains.map((t) => t.id); + return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** @@ -1396,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); const sized = await this.splitService.sizeOffer( booking, @@ -1408,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit { const offeredNeed: Capacity = { wagons: sized.offeredWagons, - weightTons: sized.offeredWeightTons, - lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + sized.offeredWeightTons, + sized.offeredWagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + sized.offeredWagons, + this.lengthsOf(wagonDims), + ), }; if (!this.fits(offeredNeed, budget)) return null; @@ -1510,7 +1611,7 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, ); - const topUpReserved = await this.fillSchedule(scheduleId); + const topUpReserved = await this.topUpFill(scheduleId); // A top-up opened a fresh pay window for waiting bookings — push the // schedule's PAYMENT phase out so the window tick's concludeCycle doesn't // fire before those customers' new deadlines and expire them prematurely. @@ -1526,7 +1627,7 @@ export class BookingBatchService implements OnModuleInit { async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); await this.settleReserved(scheduleId, true); - const topUpReserved = await this.fillSchedule(scheduleId); + const topUpReserved = await this.topUpFill(scheduleId); if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(scheduleId); } @@ -1632,11 +1733,14 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // Capture the train before expire() detaches the booking from it — the + // top-up has to run against the schedule whose wagons were just freed. + const freedScheduleId = booking.trainScheduleId; await this.expire(booking); - if (booking.trainScheduleId) { - const topUpReserved = await this.fillSchedule(booking.trainScheduleId); + if (freedScheduleId) { + const topUpReserved = await this.topUpFill(freedScheduleId); if (topUpReserved > 0) { - await this.extendPaymentPhaseForTopUp(booking.trainScheduleId); + await this.extendPaymentPhaseForTopUp(freedScheduleId); } } } @@ -1659,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); - return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + const budget = await this.remainingBudget(schedule, limits, wagonDims); + return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } /** @@ -1827,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit { * it failed to pay for — it's back in the day pool for staff to act on. */ private async expire(booking: Booking): Promise { + const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, status: "EXPIRED", @@ -1835,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // The wagons this reservation held are back — a schedule parked at FULL + // because of it must reopen, or it can never be filled again. + if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId); // An unpaid partial offer dies with the reservation — the booking stays whole. if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); @@ -1939,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, leg: CorridorLeg, budget: CorridorBudget, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { if (budget.fits(need, leg)) return true; const reservedCommercial = ( @@ -1987,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit { ); }); this.notifier.displaced(victim); - budget.add(this.needFor(victim, wagonLengths), victimLeg); + budget.add(this.needFor(victim, wagonDims), victimLeg); + // Displacing frees wagons the same way an expiry does — don't leave the + // schedule stuck at FULL. + await this.refreshWindowStatus(scheduleId); } return budget.fits(need, leg); } @@ -2041,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit { private combinedNeed( primary: Booking, partner: Booking, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Capacity { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); @@ -2050,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit { totalContainers > 0 ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) : this.wagonsFor(primary) + this.wagonsFor(partner); - const weightTons = + const cargoTons = Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); return { wagons: sharedWagons, - weightTons, - lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + // Consolidation saves tare as well as slots: the pair rides `sharedWagons` + // wagons, so it is charged `sharedWagons` tares, not one per booking. + weightTons: bookingGrossWeightTons( + cargoTons, + sharedWagons, + this.tareFor(primary.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + primary.freightType, + sharedWagons, + this.lengthsOf(wagonDims), + ), + }; + } + + /** Per-wagon tare for the wagon type this freight rides on. */ + private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { + return freightType === 'BULK' + ? wagonDims.bulk.tareWeightTons + : wagonDims.container.tareWeightTons; + } + + private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { + return { + container: wagonDims.container.lengthMeters, + bulk: wagonDims.bulk.lengthMeters, }; } @@ -2077,16 +2209,28 @@ export class BookingBatchService implements OnModuleInit { return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers); } - /** What one booking consumes along all three capacity axes. */ - private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { + /** + * What one booking consumes along all three capacity axes. + * + * The weight axis is GROSS — cargo plus the tare of every wagon the booking + * occupies — because it is spent against the locomotive's pull limit, which + * governs the whole train and not just its payload. Charging cargo alone let a + * 37-wagon box-wagon train read 2590T when it really weighed 3522T. + */ + private needFor(booking: Booking, wagonDims: WagonDims): Capacity { const wagons = this.wagonsFor(booking); return { wagons, - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), - lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + Number(booking.cargoTotalWeightVgm ?? 0), + wagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + wagons, + this.lengthsOf(wagonDims), + ), }; } @@ -2098,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit { ); } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ + /** + * Hard caps for a schedule's train: gross pull weight, train length, and the + * length-derived wagon slot count (never a fixed 53). Bookings spend against + * these via {@link needFor}, whose weight axis is gross. + */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, @@ -2143,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit { } } - private async loadWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + /** + * Every active wagon type, so the slot count is derived from the shortest wagon + * the fleet can actually marshal rather than from an arbitrary two-code sample. + */ + private async loadWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } - private async loadWagonLengths(): Promise { + /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ + private async loadWagonDims(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ where: [{ code: "NW5" }, { code: "CW3" }], }); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const nw5 = byCode.get("NW5"); + const cw3 = byCode.get("CW3"); return { - container: - byCode.get("NW5")?.lengthMeters ?? - DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: { + lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + bulk: { + lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, + }, }; } @@ -2204,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit { private async remainingBudget( schedule: TrainSchedule, limits: Capacity, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits); @@ -2216,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit { ); for (const b of [...allocated, ...reserved]) { budget.subtract( - this.needFor(b, wagonLengths), + this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } @@ -2228,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit { * ≤ 0 means no leg can take another booking — the train-wide FULL signal. */ private async remainingWagons(schedule: TrainSchedule): Promise { - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { @@ -2236,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, - wagonLengths, + wagonDims, ); return budget.maxRemaining().wagons; } @@ -2269,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit { return (await this.remainingWagons(schedule)) <= 0; } + /** + * Re-derive `bookingWindowStatus` from live capacity after wagons were freed + * (a reservation expired, a booking was displaced, a link was removed). + * + * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before + * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So + * a train that filled once and then lost every booking to expiry stayed FULL + * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT + * forever while `concludeCycle` (which reads real capacity, not the flag) kept + * reopening it. Clearing FULL here is what lets the next batch actually run. + * + * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid + * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits. + */ + async refreshWindowStatus(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "FULL") return; + if ((await this.remainingWagons(schedule)) <= 0) return; + + const customerWindowOpen = + schedule.windowPhase == null || schedule.windowPhase === "OPEN"; + await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); + this.logger.log( + `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` + + `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`, + ); + } + // ---- timer plumbing ------------------------------------------------------- /** Configured customer pay window in ms (global rules, with defaults). */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 0d9087419..f1978b762 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -328,6 +328,18 @@ export class BookingWindowService implements OnModuleInit { return; } + // Not full, so any FULL flag left over from a batch whose bookings later + // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below + // refuses to reopen a FULL schedule, which is how a train with an empty + // consist used to cycle forever without ever being fillable again. Re-read + // the flag onto the in-memory row — advanceSchedule keeps looping on this + // same object, and PRE_WINDOW→OPEN reads it. + if (schedule.bookingWindowStatus === 'FULL') { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + const fresh = await this.trainSchedulesRepository.findById(schedule.id); + if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 82fb7bd05..e1b4064bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,64 +1,183 @@ import { + bookingGrossWeightTons, bookingTrainLengthMeters, + consistUsage, + consistViolations, deriveTrainCapacityFromLocomotive, + grossWagonWeightTons, minLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { - const nw5 = { lengthMeters: 14, capacityTons: 70 }; + // Real EDR wagon specs. + const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 }; + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 }; - it('derives wagon slots from locomotive length and weight, not a fixed 53', () => { - const shortLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, - [nw5], - ); - expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14 - expect(shortLoco.maxWagonSlots).not.toBe(53); - - const heavyLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, - [nw5], - ); - expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70 + const caps = (over = {}) => ({ + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonSlots: 54, + ...over, }); - it('uses shortest wagon type when mixed types are present', () => { - const longBulk = { lengthMeters: 18, capacityTons: 80 }; - const mixed = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [nw5, longBulk], - ); - expect(mixed.maxWagonSlots).toBe( - Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)), - ); + const slots = (n: number, type: typeof nw5, cargoTons: number) => + Array.from({ length: n }, () => ({ + lengthMeters: type.lengthMeters, + tareWeightTons: type.tareWeightTons, + cargoTons, + })); + + describe('deriveTrainCapacityFromLocomotive', () => { + it('derives wagon slots from train length, not a fixed 53', () => { + const shortLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, + [nw5], + ); + expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966) + expect(shortLoco.maxWagonSlots).not.toBe(53); + }); + + it('does not shrink slots by assuming every wagon rides at full payload', () => { + // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a + // LENGTH figure — the cargo that decides weight does not exist yet. + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30 + expect(derived.maxWeightTons).toBe(2100); + }); + + it('admits the railway 53-wagon NW5 marshalling figure', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53); + }); + + it('uses the shortest wagon type when mixed types are present', () => { + const mixed = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5, pw2, gw2], + ); + expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62 + }); + + it('extends weight/length caps by the locomotive overage tolerance', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + [pw2], + ); + expect(derived.maxWeightTons).toBe(3590); + }); + + it('ignores overage tolerance when unset (strict cap)', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + + it('floors the locomotive by the global rule caps', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 }, + [nw5], + { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 }, + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + }); + + describe('gross weight', () => { + it('counts the wagon as well as its cargo', () => { + expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2); + }); + + it('charges a booking one tare per wagon it occupies', () => { + // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel. + expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2); + }); + + it('is cargo alone when the wagon type has no tare on record', () => { + expect(bookingGrossWeightTons(100, 3, 0)).toBe(100); + }); + }); + + describe('consistUsage', () => { + it('sums each wagon own length and tare rather than averaging a type', () => { + const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)]; + const usage = consistUsage(mixed, caps()); + + expect(usage.wagonCount).toBe(3); + expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066 + expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2 + expect(usage.usedCargoWeightTons).toBe(40); + expect(usage.usedGrossWeightTons).toBe(110); + expect(usage.remainingGrossWeightTons).toBe(3390); + expect(usage.remainingWagons).toBe(51); + }); + + it('reports an empty consist as fully available', () => { + const usage = consistUsage([], caps()); + expect(usage.usedGrossWeightTons).toBe(0); + expect(usage.remainingLengthMeters).toBe(760); + expect(usage.remainingWagons).toBe(54); + }); + }); + + describe('consistViolations', () => { + it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => { + // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T. + const consist = slots(37, pw2, 70); + + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([ + expect.stringContaining('3522.4T'), + ]); + expect( + consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })), + ).toEqual([]); + }); + + it('blocks a train the old cargo-only math would have waved through', () => { + // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T. + const consist = slots(37, pw2, 70); + const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0); + + expect(cargoOnly).toBeLessThan(3500); + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]); + }); + + it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => { + // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T. + expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]); + }); + + it('flags an over-length consist', () => { + const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 })); + expect(violations).toEqual([expect.stringContaining('exceeds max train length')]); + }); + + it('flags an over-count consist', () => { + const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 })); + expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]); + }); + + it('reports every broken axis at once', () => { + expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3); + }); }); it('computes booking length by freight type', () => { - expect( - bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }), - ).toBe(28); + expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); - it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => { - const pw2 = { lengthMeters: 17.066, capacityTons: 70 }; - const derived = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - [pw2], - ); - expect(derived.maxWeightTons).toBe(3590); - }); - - it('ignores overage tolerance when unset (strict cap, no behavior change)', () => { - const derived = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [{ lengthMeters: 14, capacityTons: 70 }], - ); - expect(derived.maxWeightTons).toBe(3500); - expect(derived.maxLengthMeters).toBe(760); - }); - - it('takes the weakest locomotive tolerance across a multi-locomotive set', () => { + it('takes the weakest locomotive across a multi-locomotive set', () => { const limits = minLocomotiveLimits([ { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index fb52ec40c..79adf8d8f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -1,7 +1,40 @@ +/** + * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable: + * + * count — how many wagons fit end to end on the longest allowed train + * length — Σ wagonType.lengthMeters over the real consist + * weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist + * + * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it. + * The old code compared the locomotive's pull limit against cargo payload alone + * and so overbooked every train by roughly the tare fraction (~27% on PW2). + * + * The weight axis is also driven by ACTUAL booked cargo, never by an assumed + * full payload. That is what makes the real EDR numbers fall out: + * + * NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon + * marshalling figure is length-bound, and those trains never carry 53×70T. + * PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T, + * which clears 3500T only via the locomotive's overage tolerance. Weight + * binds first, hence "37 wagons per train". + * + * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo + * exists. Weight is enforced against the consist as bookings are allocated. + */ + /** Physical dimensions used when deriving how many wagons a locomotive can pull. */ export type WagonTypeDimensions = { lengthMeters: number; capacityTons: number; + tareWeightTons: number; +}; + +/** One occupied wagon slot in a real consist. */ +export type ConsistSlot = { + lengthMeters: number; + tareWeightTons: number; + /** Actual cargo/container weight riding on this wagon, not its rated capacity. */ + cargoTons: number; }; export type LocomotiveLimits = { @@ -14,69 +47,166 @@ export type LocomotiveLimits = { }; export type DerivedTrainCapacity = { + /** Gross (tare + cargo) tons the train may weigh, tolerance included. */ maxWeightTons: number; maxLengthMeters: number; + /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; }; +/** What a consist currently uses, and what is left on each axis. */ +export type ConsistUsage = { + wagonCount: number; + usedLengthMeters: number; + /** Σ (tare + cargo). */ + usedGrossWeightTons: number; + usedTareWeightTons: number; + usedCargoWeightTons: number; + remainingLengthMeters: number; + remainingGrossWeightTons: number; + remainingWagons: number; +}; + +export const MAX_FALLBACK_WEIGHT = 3500; +export const MAX_FALLBACK_LENGTH = 760; + const DEFAULT_WAGON_LENGTH_M = 14; const DEFAULT_WAGON_CAPACITY_T = 70; +/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */ +const DEFAULT_WAGON_TARE_T = 22.4; + +function num(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ +export function grossWagonWeightTons(slot: Pick): number { + return num(slot.tareWeightTons) + num(slot.cargoTons); +} /** - * Derive train capacity from locomotive pull weight and train length. - * Wagon count is NOT a fixed 53 — it is the minimum of: - * - floor(maxLength / shortest wagon type length) - * - floor(maxWeight / lightest wagon type capacity) + * Hard caps for a train: the locomotive's own limits, floored by the global rule + * caps, then widened by the locomotive's overage tolerance. + */ +export function trainHardCaps( + locomotive: LocomotiveLimits, + ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, +): { maxWeightTons: number; maxLengthMeters: number } { + const overageTons = num(locomotive.overageToleranceTons); + const overageMeters = num(locomotive.overageToleranceMeters); + + const weight = + Math.min( + num(locomotive.maxPullWeightTons, Infinity) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ) + overageTons; + const length = + Math.min( + num(locomotive.maxTrainLengthMeters, Infinity) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ) + overageMeters; + + return { + maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT, + maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH, + }; +} + +/** + * Derive the planning capacity of a train from its locomotive. + * + * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within + * the train-length cap — the optimistic slot count, since a mixed consist of + * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by + * weight: with no bookings yet there is no cargo, and assuming every wagon rides + * at full rated payload would report 37 NW5 slots where the railway marshals 53. + * Weight is enforced by {@link consistUsage} / {@link consistViolations} against + * the cargo actually allocated. */ export function deriveTrainCapacityFromLocomotive( locomotive: LocomotiveLimits, wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { - const overageTons = Number(locomotive.overageToleranceTons) || 0; - const overageMeters = Number(locomotive.overageToleranceMeters) || 0; + const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps); - const maxWeightTons = - Math.min( - Number(locomotive.maxPullWeightTons) || Infinity, - ruleCaps?.maxTrainWeightTons ?? Infinity, - ) + overageTons; - const maxLengthMeters = - Math.min( - Number(locomotive.maxTrainLengthMeters) || Infinity, - ruleCaps?.maxTrainLengthMeters ?? Infinity, - ) + overageMeters; + const lengths = wagonTypes + .map((w) => num(w.lengthMeters)) + .filter((l) => l > 0); + const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; - const types = - wagonTypes.length > 0 - ? wagonTypes - : [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }]; + const maxWagonSlots = + minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0; - const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M)); - const minCapacity = Math.min( - ...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T), - ); + return { maxWeightTons, maxLengthMeters, maxWagonSlots }; +} - const byLength = - minLength > 0 && Number.isFinite(maxLengthMeters) - ? Math.floor(maxLengthMeters / minLength) - : 0; - const byWeight = - minCapacity > 0 && Number.isFinite(maxWeightTons) - ? Math.floor(maxWeightTons / minCapacity) - : byLength; +/** + * What a real, mixed-type consist uses on all three axes, and what is left. + * Every wagon contributes its own length and its own tare — no averaging over a + * representative wagon type. + */ +export function consistUsage( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): ConsistUsage { + let usedLengthMeters = 0; + let usedTareWeightTons = 0; + let usedCargoWeightTons = 0; - const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight)); + for (const slot of slots) { + usedLengthMeters += num(slot.lengthMeters); + usedTareWeightTons += num(slot.tareWeightTons); + usedCargoWeightTons += num(slot.cargoTons); + } + + const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons; return { - maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT, - maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH, - maxWagonSlots, + wagonCount: slots.length, + usedLengthMeters: round3(usedLengthMeters), + usedGrossWeightTons: round3(usedGrossWeightTons), + usedTareWeightTons: round3(usedTareWeightTons), + usedCargoWeightTons: round3(usedCargoWeightTons), + remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters), + remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons), + remainingWagons: caps.maxWagonSlots - slots.length, }; } -export const MAX_FALLBACK_WEIGHT = 3500; -export const MAX_FALLBACK_LENGTH = 760; +/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */ +export function consistViolations( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): string[] { + const usage = consistUsage(slots, caps); + const violations: string[] = []; + + if (usage.usedGrossWeightTons > caps.maxWeightTons) { + violations.push( + `Total train gross weight ${usage.usedGrossWeightTons}T ` + + `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` + + `exceeds max pull weight ${round3(caps.maxWeightTons)}T`, + ); + } + if (usage.usedLengthMeters > caps.maxLengthMeters) { + violations.push( + `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`, + ); + } + if (usage.wagonCount > caps.maxWagonSlots) { + violations.push( + `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`, + ); + } + + return violations; +} + +function round3(value: number): number { + return Number.isFinite(value) ? Number(value.toFixed(3)) : value; +} /** * Effective pull limits for a train set with multiple locomotives: the weakest @@ -92,18 +222,14 @@ export function minLocomotiveLimits( if (!locomotives.length) return null; return { maxPullWeightTons: Math.min( - ...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity), + ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), ), maxTrainLengthMeters: Math.min( - ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), + ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), // Weakest locomotive's tolerance governs the set, same as its caps. - overageToleranceTons: Math.min( - ...locomotives.map((l) => Number(l.overageToleranceTons) || 0), - ), - overageToleranceMeters: Math.min( - ...locomotives.map((l) => Number(l.overageToleranceMeters) || 0), - ), + overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), + overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), }; } @@ -117,12 +243,27 @@ export function bookingTrainLengthMeters( return wagonCount * perWagon; } +/** + * Gross weight a booking adds to its train: its cargo plus the tare of every + * wagon it occupies. A booking is never weightless just because it is light — + * the empty wagons still have to be pulled. + */ +export function bookingGrossWeightTons( + cargoTons: number, + wagonCount: number, + tarePerWagonTons: number, +): number { + return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); +} + export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; + tareWeightTons?: number | string | null; }): WagonTypeDimensions { return { - lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, - capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, + capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 79b009d79..0c366ed05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -102,10 +102,13 @@ import { deriveTrainCapacityFromLocomotive, minLocomotiveLimits, wagonTypeDimensionsFromEntity, + WagonTypeDimensions, } from './train-capacity.util'; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; import { computeExportWindowTimes, @@ -3125,16 +3128,27 @@ export class TrainSchedulingService { }; } - private async loadSchedulingWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], - }); + /** + * Every active wagon type: the slot count derives from the shortest wagon the + * fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at + * 12.228m) and under-report how many wagons the train length allows. + */ + private async loadSchedulingWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 0221144b3..30a46a099 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -35,6 +36,9 @@ export type WagonPlanSlot = { wagonTypeCode: string; capacityTons: number; lengthMeters: number; + /** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */ + tareWeightTons: number; + /** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */ assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; @@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number { return Number(numericValue.toFixed(3)); } +/** + * Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill; + * a missing tare must read as 0 rather than silently inventing dead weight. + */ +export function tareTonsOf(wagonType: Pick): number { + return roundTons(wagonType.tareWeightTons ?? 0); +} + /** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; @@ -146,6 +158,7 @@ export function buildContainerWagonPlan( wagonTypeCode: wagonType.code, capacityTons: Number(wagonType.capacityTons), lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -175,6 +188,7 @@ export function buildBulkWagonPlan( wagonTypeCode: wagonType.code, capacityTons: capacity, lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -214,6 +228,7 @@ export function buildMixedWagonPlan( wagonTypeCode: containerWagonType.code, capacityTons: Number(containerWagonType.capacityTons), lengthMeters: Number(containerWagonType.lengthMeters), + tareWeightTons: tareTonsOf(containerWagonType), assignedWeightTons: 0, allocations: [], slotLoadType: 'CONTAINER', @@ -443,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string return violations; } +/** + * Check a consist against its train's three limits. Weight is GROSS — every slot + * contributes its own tare plus the cargo assigned to it — because the locomotive + * pull limit governs what it drags, not what was sold. Length and tare are summed + * per slot, so a mixed consist is measured as it actually stands rather than + * through one representative wagon type. + * + * `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain` + * is absent; slot dimensions always win over it. + */ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], wagonType: Pick, limits?: TrainLimitConfig, ): string[] { - const violations: string[] = []; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const wagonLength = Number(wagonType.lengthMeters) || 14; - const maxWagonsPerTrain = - limits?.maxWagonsPerTrain ?? - Math.floor(maxLengthMeters / wagonLength); + const maxWagonSlots = + limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength); - const totalWeightTons = roundTons( - wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + const violations = consistViolations( + wagonPlan.map((slot) => ({ + lengthMeters: Number(slot.lengthMeters), + tareWeightTons: Number(slot.tareWeightTons ?? 0), + cargoTons: Number(slot.assignedWeightTons), + })), + { maxWeightTons, maxLengthMeters, maxWagonSlots }, ); - const totalLengthMeters = roundTons( - wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), - ); - - if (totalWeightTons > maxWeightTons) { - violations.push( - `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, - ); - } - if (totalLengthMeters > maxLengthMeters) { - violations.push( - `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, - ); - } - if (wagonPlan.length > maxWagonsPerTrain) { - violations.push( - `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, - ); - } violations.push(...validateBulkWagonSlotWeights(wagonPlan)); return violations; } +/** + * Mixed consist: the wagon-count fallback uses the shortest type present, since + * that is the most wagons that could ever fit. Weight and length still come from + * the slots themselves. + */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: WagonType[], diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 33fa0bcb0..49bbd1f5f 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -56,6 +56,17 @@ export class CreateWagonTypeDto { @Min(0.001) lengthMeters!: number; + @ApiProperty({ + description: + 'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' + + 'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.', + example: 22.4, + }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + tareWeightTons!: number; + @ApiPropertyOptional({ description: 'Supported load types, e.g. CONTAINER,BULK', type: [String], diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index e418460c4..b3e410f4b 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -28,8 +28,9 @@ export class WagonType extends BaseEntity { @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) equatedLengthM?: number | null; - @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tareWeightTons?: number | null; + /** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + tareWeightTons!: number; @Column({ name: 'supports_container', type: 'boolean', default: false }) supportsContainer!: boolean; diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 79cecf659..a61880f06 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -80,6 +80,7 @@ export class WagonTypesService { name: dto.name.trim(), capacityTons: dto.capacityTons, lengthMeters: dto.lengthMeters, + tareWeightTons: dto.tareWeightTons ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 1f43f322c..2f8fa67fd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -536,6 +536,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -586,6 +587,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -599,6 +601,7 @@ export function WagonTypesCrudPage() { name: type.name ?? '', capacityTons: type.capacityTons ?? 0, lengthMeters: type.lengthMeters ?? 0, + tareWeightTons: type.tareWeightTons ?? '', supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '', isActive: type.isActive, }); @@ -607,10 +610,17 @@ export function WagonTypesCrudPage() { const validateWagonType = () => { const errors: Record = {}; + // normalizePayload strips empty strings, so a blank numeric field would be + // dropped from the payload rather than rejected. Each must be a positive + // number here — the API's @Min(0.001) agrees. + const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0; + if (!String(form.code ?? '').trim()) errors.code = 'Code is required'; if (!String(form.name ?? '').trim()) errors.name = 'Name is required'; - if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number'; - if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number'; + if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0'; + if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0'; + if (!positive(form.tareWeightTons)) + errors.tareWeightTons = 'Tare weight is required and must be greater than 0'; return errors; }; @@ -707,6 +717,7 @@ export function WagonTypesCrudPage() { Length (m) + Tare weight (tons) Load types Status Actions @@ -719,6 +730,7 @@ export function WagonTypesCrudPage() { {type.name} {type.capacityTons} {type.lengthMeters} + {type.tareWeightTons ?? '-'} {type.supportedLoadTypes?.join(', ') || '-'} @@ -747,7 +759,7 @@ export function WagonTypesCrudPage() { ))} {!query.isLoading && filtered.length === 0 ? ( - + No wagon types found. @@ -756,7 +768,7 @@ export function WagonTypesCrudPage() { ) : null} {query.isLoading ? ( - + Loading... @@ -811,6 +823,15 @@ export function WagonTypesCrudPage() { error={fieldErrors.lengthMeters} onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))} /> + setForm((current) => ({ ...current, tareWeightTons: value }))} + /> statusBadge(locomotive.status) }, { key: 'maxPullWeightTons', label: 'Max pull (tons)' }, { key: 'maxTrainLengthMeters', label: 'Max length (m)' }, + { + key: 'overageToleranceTons', + label: 'Weight tolerance (t)', + render: (locomotive) => locomotive.overageToleranceTons ?? '-', + }, + { + key: 'overageToleranceMeters', + label: 'Length tolerance (m)', + render: (locomotive) => locomotive.overageToleranceMeters ?? '-', + }, ]} fields={[ { key: 'code', label: 'Code', required: true }, @@ -1137,6 +1168,11 @@ export function LocomotivesCrudPage() { }, { key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true }, { key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' }, + { key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' }, { key: 'powerKw', label: 'Power (kW)', type: 'number' }, { key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' }, { key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' }, @@ -1148,6 +1184,8 @@ export function LocomotivesCrudPage() { status: 'AVAILABLE', maxPullWeightTons: 0, maxTrainLengthMeters: 760, + overageToleranceTons: '', + overageToleranceMeters: '', powerKw: '', tractionForceKn: '', maxSpeedKmh: '', diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3ac7f2458..9e6ef7169 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" }, { id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" }, + { id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" }, + { id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" }, ], // Code is auto-generated server-side (LOCO-NNN) — omitted from the form. formFields: [ @@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true }, { name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" }, + { name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" }, { name: "powerKw", label: "Power (kW)", type: "number" }, { name: "tractionForceKn", label: "Traction force (kN)", type: "number" }, { name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" }, @@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ currentYardId: "", maxPullWeightTons: 2500, maxTrainLengthMeters: 760, + overageToleranceTons: "", + overageToleranceMeters: "", powerKw: "", tractionForceKn: "", maxSpeedKmh: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index b02bf7e22..1b7e490e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -175,6 +175,7 @@ function CapacityChip({ ); } +/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */ function weightPctOf(s: BatchBoardSchedule) { return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 @@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) { ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 : null; } +function wagonPctOf(s: BatchBoardSchedule) { + return s.capacity.maxWagons && s.capacity.maxWagons > 0 + ? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100 + : null; +} function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); @@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const lengthPct = lengthPctOf(schedule); const weightPct = weightPctOf(schedule); + const wagonPct = wagonPctOf(schedule); const totalBookings = totalBookingCount(counts); @@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { ) : null} - {/* capacity: weight + length rings + wagons (numbers preserved) */} + {/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */} ) : null} - - - - - - {capacity.allocatedWagons} - - - Wagons - - - allocated - - + {wagonPct != null ? ( + + ) : ( + + + + + + {capacity.allocatedWagons} + + + Wagons + + + allocated + + + )} {lengthPct != null ? ( ( - - - - - - - {row.original.capacity.allocatedWagons} - - - wgn - + cell: ({ row }) => { + const { allocatedWagons, maxWagons } = row.original.capacity; + const wagonPct = wagonPctOf(row.original); + return ( + + + + {wagonPct != null ? ( + + ) : ( + + + + {allocatedWagons} + + + wgn + + + )} - - ), + ); + }, }, { id: "bookings", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 6daf79221..3684dd1e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() { items={[ { label: "Allocated wagons", - value: data.capacity.allocatedWagons, + value: data.capacity.maxWagons + ? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}` + : data.capacity.allocatedWagons, hint: "on this train", icon: Boxes, }, @@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() { icon: Ruler, }, { - label: "Weight", + label: "Gross weight", value: data.capacity.maxWeightTons ? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}` : fmtTons(data.capacity.usedWeightTons), + hint: "wagon tare + cargo", icon: Weight, }, { diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index f904eec5c..3c96b3cfb 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -27,6 +27,10 @@ export interface Locomotive { currentYard?: { id: string; label?: string; code?: string } | null; maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */ + overageToleranceTons?: number | null; + /** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */ + overageToleranceMeters?: number | null; powerKw?: number | null; tractionForceKn?: number | null; maxSpeedKmh?: number | null; diff --git a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts index 699c0b2fc..bb4eead1f 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts @@ -8,6 +8,8 @@ export interface WagonType { name: string; capacityTons: number; lengthMeters: number; + /** Empty wagon weight; required, since pull limits apply to tare + cargo. */ + tareWeightTons: number; supportedLoadTypes: string[]; isActive: boolean; } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index ab14a18da..c09c067d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -280,10 +280,13 @@ export interface BatchBoardSchedule { capacity: { allocatedWagons: number; allocatedLengthMeters: number; + /** Train-length cap: locomotive floored by global rules, plus overage tolerance. */ maxLengthMeters: number | null; + /** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */ usedWeightTons: number; + /** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */ maxWeightTons: number | null; - /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + /** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */ maxWagons: number | null; }; counts: {