From 57098015901b4df61030e1a1b9e972e0b207d152 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 17:25:01 +0000 Subject: [PATCH] enhance shipment form and booking process --- .../contracts/contract-booking.service.ts | 2 + .../dto/create-booking-under-contract.dto.ts | 7 + .../train-schedules.repository.ts | 7 +- .../booking-batch.service.spec.ts | 61 +- .../train-scheduling/booking-batch.service.ts | 159 +++-- .../train-scheduling/fleet-plan.util.ts | 3 +- .../train-capacity.util.spec.ts | 32 + .../train-scheduling/train-capacity.util.ts | 46 +- .../train-scheduling.service.ts | 83 ++- .../train-scheduling/wagon-plan.util.spec.ts | 52 ++ .../train-scheduling/wagon-plan.util.ts | 56 +- .../contracts/GlCreateBookingForm.tsx | 48 +- .../trainScheduling/scheduleVisuals.tsx | 31 +- .../TrainScheduleV2DetailPage.tsx | 1 + .../backoffice/src/types/booking.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 2 + .../src/pages/contracts/NewContractPage.tsx | 15 +- .../src/pages/contracts/NewShipmentPage.tsx | 26 +- .../new-contract-form/contractToForm.ts | 16 +- .../contracts/new-contract-form/schema.ts | 27 +- .../new-contract-form/step1-contract-type.tsx | 9 +- .../new-contract-form/step3-cargo-scope.tsx | 216 ++---- .../contracts/new-shipment-form/schema.ts | 31 +- .../cypress/e2e/flows/segment_weight.cy.ts | 634 ++++++++++++++++++ .../cypress/fixtures/seed-segment-weight.sql | 175 +++++ packages/types/src/freight/contracts.ts | 2 + 26 files changed, 1413 insertions(+), 330 deletions(-) create mode 100644 e2e/freight/cypress/e2e/flows/segment_weight.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-segment-weight.sql diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a4360447d..96357c8c4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -289,6 +289,7 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), @@ -738,6 +739,7 @@ export class ContractBookingService { } await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 3b5a30ca0..93a0e9e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + description: 'What the containers carry — captured per booking (container freight).', + }) + @IsOptional() + @IsString() + cargoFreeText?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 08b906e84..294fb93f1 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, - cargoType: true, + // wagonTypes feed grossBookingWeightTons the REAL tare of the + // wagon type the booking rides — without them it falls back to + // default tares and the workspace gross drifts from the validator. + bookingContainers: { containerType: { wagonTypes: true } }, + cargoType: { wagonTypes: true }, }, }, }, 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 bafc7a13e..b308a5921 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 @@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { reserved: Booking[]; maxWagons?: number; routeStops?: string[]; + yardCountries?: Record; }) => { const schedule = { id: scheduleId, @@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => { find: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; + const yardRepo = { + find: jest + .fn() + .mockResolvedValue( + Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({ + id, + country, + })), + ), + }; const dataSource = { getRepository: jest.fn((entity: { name?: string }) => { if (entity?.name === 'Wagon') return wagonRepo; if (entity?.name === 'RouteMilestone') return milestoneRepo; + if (entity?.name === 'Yard') return yardRepo; return genericRepo; }), transaction: jest.fn(), @@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); - it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => { - // Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor - // left the pass-through edges reading "free" in the per-edge budget, so the - // full train's window cycled OPEN forever and the day pool never expired. - // A wagon is committed for the whole trip — leg-free edges are not capacity. + it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => { + // Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real + // capacity on the edges they don't ride: a domestic corridor with cargo + // only on m1→m2 still boards bookings on the free first/last edges, so the + // window must stay open for them. const { service } = buildService({ physicalWagons: 2, routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'], @@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => { reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }), ], }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => { + // Export b→c holds every wagon of the border crossing: no further export + // can board anywhere (they all must ride that edge), so the window closes — + // while intercity keeps booking the free a→b leg through the per-leg budget. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }), + reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }), + ], + }); await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); + it('is NOT full while the border edge still has room, even with a home leg sold out', async () => { + // Intercity rode a→b on both wagons; the border edge b→dj is still free, + // so exports can still board — the window stays open. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }), + reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }), + ], + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { const { service } = buildService({ physicalWagons: 1, 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 03a125aef..591812880 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 @@ -24,9 +24,9 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; -import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -60,11 +60,14 @@ import { DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { + LocomotiveLimits, WagonTypeDimensions, + bookingCargoTons, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, + trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; @@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( target.scheduleId, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return false; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); @@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit { } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) { throw new ConflictException( "Export train is no longer available for reservation", @@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - const loco = s.trainSet?.locomotive ?? null; + const loco = trainSetLocomotiveLimits(s.trainSet); // The board renders ONE booking window — the schedule's own frozen window // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings @@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit { trainName: s.trainSet.train.trainName ?? null, } : null, + // Identity from the primary (legacy) locomotive; limit figures from the + // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, + code: s.trainSet?.locomotive?.code ?? '', + name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } @@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit { weightTons: number; lengthMeters: number; }>, - loco: Locomotive | null, + loco: LocomotiveLimits | null, maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); @@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit { s: TrainSchedule, items: BatchBoardBooking[], ): BatchBoardSchedule { - const loco = s.trainSet?.locomotive ?? null; + const loco = trainSetLocomotiveLimits(s.trainSet); return { scheduleId: s.id, @@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit { trainName: s.trainSet.train.trainName ?? null, } : null, + // Identity from the primary (legacy) locomotive; limit figures from the + // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, + code: s.trainSet?.locomotive?.code ?? '', + name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } @@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule || !this.isFillable(schedule)) return 0; - const locomotive = schedule.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, @@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit { for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${id} has no locomotive/train set — skipped.`, @@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit { } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); @@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); - const cargoTons = - Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner); // Consolidation shares TEU slots, never rated payload: the pair still needs // enough wagons to carry its combined cargo, so the weight axis bounds the @@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; - const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); + const cargoTons = bookingCargoTons(booking); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; @@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit { return { wagons, weightTons: bookingGrossWeightTons( - Number(booking.cargoTotalWeightVgm ?? 0), + bookingCargoTons(booking), wagons, dims.tareWeightTons, ), @@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit { * caps deliberately do not apply here (a mis-set global row once capped * every train at 14m and no export booking could board). */ - private async capacityLimits(locomotive: Locomotive): Promise { + private async capacityLimits(locomotive: LocomotiveLimits): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit { */ private async syncScheduleMaxWagons( schedule: TrainSchedule, - locomotive: Locomotive, + locomotive: LocomotiveLimits, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); const maxWagons = @@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * Built train: FULL when every physical wagon slot is taken — the consist is - * the capacity, weight/length were settled at build time. - * No built train: FULL on ANY capacity axis — out of wagon slots, or out of - * pull weight / train length for even one more loaded wagon. The old - * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = - * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever - * instead of finalizing — 7 phantom slots kept it "not full" while nothing - * could board. + * FULL is DIRECTIONAL: the schedule's trade direction is full when the + * border-crossing edge (which every export/import must ride) can't take one + * more minimal wagon on any axis — slots for built trains (the consist is + * the capacity, weight/length settled at build), all three axes otherwise + * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). + * Home-side legs may still run empty; intercity ride-alongs keep filling + * them via the per-leg budget and never consult this flag. Domestic routes + * (no border) are full only when every edge is closed. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { - // Built train: the physical consist is the only capacity axis, and a wagon - // is committed to its booking for the WHOLE trip — wagon allocation has no - // leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for - // the Doraleh→Negad edge it merely passes through. Count commitments - // train-wide, not per corridor edge: the per-edge budget read "free slots" - // on pass-through legs of a sold-out consist, so the window of a full train - // cycled OPEN forever instead of concluding DONE (and the day pool's - // leftover bookings were never expired). - const physicalWagons = await this.builtTrainWagonCount(schedule); - if (physicalWagons != null) { - return (await this.committedWagons(schedule)) >= physicalWagons; - } - if ((await this.remainingWagons(schedule)) <= 0) return true; - const locomotive = schedule.trainSet?.locomotive; - if (!locomotive) return false; // no weight/length limits to bind against + // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and + // every import must cross the ET↔DJ border edge, so once that edge can't + // take one more minimal wagon the booking window may close — even while + // home-side legs still run empty. Intercity ride-alongs never consult this + // flag; they keep booking the free legs through the per-leg budget. + // A single-country (domestic) corridor has no mandatory edge, so it is + // full only when EVERY edge is closed on some axis. const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive); + const physicalWagons = await this.builtTrainWagonCount(schedule); + let limits: TrainLimits; + if (physicalWagons != null) { + // The consist is the capacity; weight/length were settled at build time. + // remainingBudget swaps in the physical wagon count per edge itself. + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } else { + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); + // No loco, no built train: only the slot axis exists to bind against. + if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; + limits = await this.capacityLimits(locomotive); + } const budget = await this.remainingBudget(schedule, limits, wagonDims); - return budget.isExhausted(this.minPerWagonNeed(wagonDims)); + const minNeed = this.minPerWagonNeed(wagonDims); + const border = await this.borderLeg(budget.stops); + if (border) { + return !budget.fits( + { + wagons: 1, + weightTons: minNeed.grossWeightTons, + lengthMeters: minNeed.lengthMeters, + }, + border, + ); + } + return budget.isExhausted(minNeed); } /** - * Wagons the schedule's allocated + reserved bookings occupy train-wide, - * regardless of which corridor leg each rides. Deduped by booking id — a - * booking mid-settle can momentarily be both linked and reserved. + * The corridor's single border-crossing edge (last home-country stop → first + * far-country stop), or null when every stop is in one country. This is the + * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it + * books — which makes it the train's directional fullness gauge. */ - private async committedWagons(schedule: TrainSchedule): Promise { - const wagonDims = await this.loadWagonDims(); - const allocated = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, - ); - const byId = new Map( - [...allocated, ...reserved].map((b) => [b.id, b] as const), - ); - let total = 0; - for (const booking of byId.values()) { - total += this.wagonsFor(booking, wagonDims); - } - return total; + private async borderLeg(stops: string[]): Promise { + if (stops.length < 2) return null; + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(stops) } }); + const countryOf = new Map(yards.map((y) => [y.id, y.country])); + const first = countryOf.get(stops[0]); + if (!first) return null; + const crossIdx = stops.findIndex((id) => { + const country = countryOf.get(id); + return country != null && country !== first; + }); + if (crossIdx <= 0) return null; + return { fromEdge: crossIdx - 1, toEdge: crossIdx }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 4d408689b..cacffaba4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,3 +1,4 @@ +import { bookingCargoTons } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -187,5 +188,5 @@ export function summarizeFleetWarnings( } export function totalAssignedWeight(bookings: Booking[]): number { - return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0)); + return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0)); } 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 dd9234bdb..8342eae47 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 @@ -7,6 +7,7 @@ import { grossWagonWeightTons, minLocomotiveLimits, sizePartialOfferWagons, + trainSetLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { @@ -205,6 +206,37 @@ describe('train-capacity.util', () => { expect(limits?.overageToleranceTons).toBe(20); }); + it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => { + // LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must + // keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train. + const limits = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null }, + ]); + expect(limits?.overageToleranceTons).toBe(90); + // All unconfigured → no tolerance. + const none = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + ]); + expect(none?.overageToleranceTons).toBe(0); + }); + + it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => { + const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; + const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null }; + expect( + trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }), + ).toEqual({ + maxPullWeightTons: 3500, + maxTrainLengthMeters: 700, + overageToleranceTons: 90, + overageToleranceMeters: 0, + }); + expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500); + expect(trainSetLocomotiveLimits(null)).toBeNull(); + expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull(); + }); + describe('sizePartialOfferWagons', () => { it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => { // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2 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 3e4fab0d3..b4b3a64de 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 @@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } +/** + * Cargo tons of a booking: the stored VGM total when present, else the sum of + * its container lines (quantity × VGM per unit). The portal's container flow + * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the + * total alone made every such booking weigh only its tare. + */ +export function bookingCargoTons(booking: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + quantity?: number | null; + vgmPerUnitTons?: number | string | null; + }> | null; +}): number { + const total = num(booking.cargoTotalWeightVgm); + if (total > 0) return total; + return (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons), + 0, + ); +} + /** 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); @@ -260,11 +281,34 @@ export function minLocomotiveLimits( }; } -function minConfigured(values: Array): number { +function minConfigured(values: Array): number { const configured = values.filter((v) => v != null).map((v) => num(v)); return configured.length ? Math.min(...configured) : 0; } +/** + * Effective limits for a whole train set: min across its linked locomotives, + * falling back to the legacy single `locomotive` column for sets created + * before multi-loco support. Null when the set has no locomotive at all. + */ +export function trainSetLocomotiveLimits( + trainSet?: { + locomotive?: LocomotiveLimits | null; + locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null; + } | null, +): LocomotiveLimits | null { + if (!trainSet) return null; + const linked = (trainSet.locomotives ?? []) + .map((link) => link.locomotive) + .filter((l): l is LocomotiveLimits => Boolean(l)); + const pool = linked.length + ? linked + : trainSet.locomotive + ? [trainSet.locomotive] + : []; + return minLocomotiveLimits(pool); +} + /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, 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 13624691a..c7d1f8537 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 @@ -122,6 +122,7 @@ import { roundTons, sumWagonsRequired, type TrainLimitConfig, + maxEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, @@ -130,9 +131,12 @@ import { import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { + bookingCargoTons, deriveTrainCapacityFromLocomotive, minLocomotiveLimits, + trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, + LocomotiveLimits, WagonTypeDimensions, } from './train-capacity.util'; import { @@ -1706,25 +1710,36 @@ export class TrainSchedulingService { relations: { wagonType: true }, }) : null; - const planTareTons = consistWagons + const planTareTons = roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); + const consistTareTons = consistWagons ? roundTons( consistWagons.reduce( (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0), 0, ), ) - : roundTons( - wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), - ); - const grossWeightTons = roundTons(totalWeightTons + planTareTons); + : planTareTons; + // The pull limit binds on the HEAVIEST LEG, not the whole-route sum — + // disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never + // hauled at the same time. Coupled-but-unplanned wagons ride every edge, + // so their tare rides on top of the binding edge. + const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); + const edgeUsage = maxEdgeConsistUsage( + wagonPlan, + await this.stopYardsForSchedule(schedule), + ); + const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons); if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( - `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`, + `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`, ); } - if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { + const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); + if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) { throw new BadRequestException( - `Train set locomotives cannot support ${totalLengthMeters}m`, + `Train set locomotives cannot support ${maxEdgeLengthMeters}m`, ); } @@ -4086,9 +4101,6 @@ export class TrainSchedulingService { } const totalWeightTons = totalAssignedWeight(fittingBookings); - // Every weight limit below (global max, loco pull) is a GROSS axis, so the - // figure spent against it must be gross too — cargo alone under-reports the - // train by the full consist tare and disagrees with the assign path. const totalTareTons = roundTons( wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), ); @@ -4096,12 +4108,13 @@ export class TrainSchedulingService { const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); - if (grossWeightTons > trainLimits.maxWeightTons) { - const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message) && !warnings.includes(message)) { - pushLimit([message]); - } - } + // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge + // above — the whole-route totals here are informational (summary) only. The + // locomotive checks below also compare the heaviest single edge: a train is + // never heavier than its heaviest leg, so disjoint legs must not be summed. + const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops); + const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons); + const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { @@ -4124,9 +4137,9 @@ export class TrainSchedulingService { if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - grossWeightTons || + maxEdgeGrossTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < - totalLengthMeters) + maxEdgeLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', @@ -4145,9 +4158,9 @@ export class TrainSchedulingService { !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= - grossWeightTons && + maxEdgeGrossTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= - totalLengthMeters, + maxEdgeLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); @@ -4206,10 +4219,7 @@ export class TrainSchedulingService { maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, - locomotive?: Pick< - Locomotive, - 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' - >, + locomotive?: LocomotiveLimits | null, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -6517,7 +6527,7 @@ export class TrainSchedulingService { >, tareDims: Awaited>, ): number { - const cargo = Number(booking.cargoTotalWeightVgm ?? 0); + const cargo = bookingCargoTons(booking); const fallback = booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container; // Same first-configured-type resolution the batch engine's dimsFor uses. @@ -6883,6 +6893,18 @@ export class TrainSchedulingService { // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. stops: this.mapScheduleStops(schedule), + // Gross ceiling the validator holds each leg to: the set's weakest + // locomotive pull limit plus its overage tolerance. Booking weightTons + // above are gross too, so the strip can sum them per leg against this. + maxGrossWeightTons: (() => { + const setLimits = trainSetLocomotiveLimits(schedule.trainSet); + return setLimits + ? roundTons( + Number(setLimits.maxPullWeightTons) + + (Number(setLimits.overageToleranceTons) || 0), + ) + : null; + })(), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -6978,7 +7000,10 @@ export class TrainSchedulingService { originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, }; - const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive); + const limits = await this.resolveTrainLimitConfig( + undefined, + trainSetLocomotiveLimits(schedule.trainSet), + ); const validation = await this.validateBookingsForScheduling( previewDto, @@ -7104,7 +7129,7 @@ export class TrainSchedulingService { }; const limits = await this.resolveTrainLimitConfig( undefined, - schedule.trainSet.locomotive, + trainSetLocomotiveLimits(schedule.trainSet), ); let validation: Awaited>; @@ -7648,7 +7673,7 @@ export class TrainSchedulingService { }; const limits = await this.resolveTrainLimitConfig( undefined, - schedule.trainSet.locomotive, + trainSetLocomotiveLimits(schedule.trainSet), ); let validation: Awaited>; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index c3d48f286..a7d430b91 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -9,6 +9,7 @@ import { containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, + maxEdgeConsistUsage, roundTons, sumWagonsRequired, validate20ftContainerRules, @@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([])).toBe(0); }); }); + +describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => { + const slot = ( + tare: number, + cargo: number, + length: number, + board?: string | null, + alight?: string | null, + ) => + ({ + tareWeightTons: tare, + assignedWeightTons: cargo, + lengthMeters: length, + boardYardId: board ?? null, + alightYardId: alight ?? null, + }) as never; + + const stops = ['a', 'b', 'c']; + + it('does not sum disjoint legs: intercity a→b + export b→c', () => { + const plan = [ + slot(24, 65, 14, null, 'b'), // intercity, rides a→b only + slot(24, 65, 14, 'b', null), // export, rides b→c only + ]; + // Each edge carries one slot: 89T gross / 14m — never 178T. + expect(maxEdgeConsistUsage(plan, stops)).toEqual({ + grossWeightTons: 89, + lengthMeters: 14, + }); + }); + + it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => { + // 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo: + // shared edge a→b carries all 40 slots = 3547.6T gross. + const plan = [ + ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')), + ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)), + ]; + const usage = maxEdgeConsistUsage(plan, stops); + expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1); + expect(usage.lengthMeters).toBe(560); + }); + + it('degrades to whole-train totals on a two-stop route', () => { + const plan = [slot(24, 65, 14), slot(24, 65, 14)]; + expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ + grossWeightTons: 178, + lengthMeters: 28, + }); + }); +}); 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 1aa555531..84a2dc1f5 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 @@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge( stops: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); - const lastIdx = stops.length - 1; - const spans = wagonPlan.map((slot) => { - const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; - const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; - // A yard missing from the stop list keeps the slot on the whole route. - return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; - }); + const spans = slotSpans(wagonPlan, stops); const violations = new Set(); - for (let edge = 0; edge < lastIdx; edge += 1) { + for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = wagonPlan.filter( (_, i) => spans[i].from <= edge && edge < spans[i].to, ); @@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge( return [...violations]; } +/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ +function slotSpans( + wagonPlan: WagonPlanSlot[], + stops: string[], +): Array<{ from: number; to: number }> { + const lastIdx = stops.length - 1; + return wagonPlan.map((slot) => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; + return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; + }); +} + +/** + * The corridor's binding edge: gross tons (tare + assigned cargo) and length + * summed over only the slots riding each edge, maxed across edges. This is the + * figure a locomotive pull/length limit must be compared against — a train is + * never heavier than its heaviest single leg, so summing disjoint legs + * (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train. + * Two stops or fewer degrade to the whole-train totals. + */ +export function maxEdgeConsistUsage( + wagonPlan: WagonPlanSlot[], + stops: string[], +): { grossWeightTons: number; lengthMeters: number } { + const totals = (slots: WagonPlanSlot[]) => ({ + grossWeightTons: slots.reduce( + (sum, w) => + sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), + 0, + ), + lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), + }); + if (stops.length <= 2) return totals(wagonPlan); + const spans = slotSpans(wagonPlan, stops); + const usage = { grossWeightTons: 0, lengthMeters: 0 }; + for (let edge = 0; edge < stops.length - 1; edge += 1) { + const active = totals( + wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), + ); + usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); + usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); + } + return usage; +} + export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index bc9521dd3..fe04fba99 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -269,6 +269,8 @@ export default function GlCreateBookingForm() { const [scheduledDate, setScheduledDate] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); + // What the containers carry — captured per booking (moved off the contract). + const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulk, setBulk] = useState({ cargoWeightTons: "", @@ -459,6 +461,10 @@ export default function GlCreateBookingForm() { ); }; setPrefilled(true); + // Rebook carries the expired booking's cargo description forward. + if (copyFromBooking.cargoFreeText) { + setCargoDescription(copyFromBooking.cargoFreeText); + } setContainerLines( lines.map((c) => { const qty = Math.max(1, c.quantity); @@ -705,14 +711,23 @@ export default function GlCreateBookingForm() { const lineErrors = useMemo(() => { if (!isContainer || !contract) return []; - return containerLines.map((line) => { + // A line can be 0 (the contract covers both sizes; a booking may only need + // one) but the booking as a whole needs at least one container — anchor + // that error on the first line's quantity so it renders in the field. + const totalQty = containerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + return containerLines.map((line, idx) => { const errs: LineErrors = {}; const qty = Number(line.quantity || 0); if (line.quantity.trim() === "") { errs.quantity = "Quantity is required."; - } else if (Number.isNaN(qty) || qty < 1) { - errs.quantity = "At least 1."; - } else if (line.units.length < qty) { + } else if (Number.isNaN(qty) || qty < 0) { + errs.quantity = "Enter 0 or more."; + } else if (idx === 0 && totalQty < 1) { + errs.quantity = "Book at least one container (either size)."; + } else if (qty >= 1 && line.units.length < qty) { errs.units = `Enter details for all ${qty} container(s).`; } if (contract.isHazardous) { @@ -789,6 +804,11 @@ export default function GlCreateBookingForm() { const routeError = multiRoute && !contractRouteId ? "Select a route." : undefined; + const cargoDescriptionError = + isContainer && !cargoDescription.trim() + ? "Describe the cargo carried in the containers." + : undefined; + const cargoValid = isContainer ? lineErrors.every( (e) => @@ -800,7 +820,8 @@ export default function GlCreateBookingForm() { ) && unitErrors.every((line) => line.every((e) => !e.containerNumber && !e.vgmTons), - ) + ) && + !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; @@ -827,6 +848,8 @@ export default function GlCreateBookingForm() { }; if (isContainer) { + // What the containers carry — captured per booking, not on the contract. + if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim(); payload.containers = containerLines .filter((l) => Number(l.quantity) >= 1) .map((l) => ({ @@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() { )} {remainderNotice} +