From 488c2465befef628f2269554b4010adb9e5089ba Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 19:16:28 +0000 Subject: [PATCH] feat: add per-ton cargo loading limits and update related services --- ...90000000000-AddCargoTypeTonsPerWagonMap.ts | 25 ++++++ .../bookings/booking-pricing.service.ts | 8 +- .../rule-engine/dto/create-cargo-type.dto.ts | 13 +++ .../rule-engine/entities/cargo-type.entity.ts | 11 +++ .../modules/rule-engine/rule-engine.module.ts | 4 + .../services/cargo-types.service.ts | 90 ++++++++++++++++++- .../train-scheduling/booking-batch.service.ts | 21 ++++- .../train-scheduling/fleet-plan.util.ts | 8 +- .../train-capacity.util.spec.ts | 59 ++++++++++++ .../train-scheduling/train-capacity.util.ts | 90 +++++++++++++++++++ .../train-scheduling.service.ts | 7 +- .../train-scheduling/wagon-plan-flex.util.ts | 11 +-- .../train-scheduling/wagon-plan.util.ts | 36 +++++++- .../src/pages/ruleEngine/CargoTypesPage.tsx | 32 ++++++- 14 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts diff --git a/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts new file mode 100644 index 000000000..04fb6c1aa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * PER_TON (bulk) cargo can have a loading limit BELOW the wagon's rated + * capacity: sugar rides 50T on a 70T wagon (density/stowage/policy), so 200T + * needs 4 wagons, not the 3 that raw capacity implies. Stored as a jsonb map + * { [wagonTypeId]: maxTons } on cargo_types — the PER_TON mirror of + * items_per_wagon_map. Unset (or no key) means the wagon's full rated capacity, + * so existing cargo types keep their current behaviour with no backfill. + */ +export class AddCargoTypeTonsPerWagonMap3190000000000 implements MigrationInterface { + name = 'AddCargoTypeTonsPerWagonMap3190000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index b44c1c5e0..9bbe6bed5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,7 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; -import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; +import { bulkWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1190,8 +1190,10 @@ export class BookingPricingService { // Break-bulk (PER_ITEM): `tons` above is the item count; size by // indivisible items instead of pretending the count is tonnage. Best // count across allowed wagon types, each capped by its items-fit. - const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a + // 70T wagon) — 200T then prices 4 wagons, not 3. + const byWagons = bulkWagonsForAllowedTypes(booking, cargo, capacity); + if (byWagons > 0) return byWagons; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6d9cb82a2..eefc560e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -44,6 +44,19 @@ export class CreateCargoTypeDto { @IsObject() itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ + description: + 'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' + + 'wagon-type id (e.g. { "": 50 } loads sugar 50T on a 70T wagon, so 200T ' + + 'takes 4 wagons). Optional — omit a wagon type to use its full rated capacity. ' + + 'Rejected when it exceeds that wagon type\'s rated capacity.', + type: 'object', + additionalProperties: { type: 'number', minimum: 0.001 }, + }) + @IsOptional() + @IsObject() + tonsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b096613bf..e685f3a2d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -60,6 +60,17 @@ export class CargoType extends BaseEntity { @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) itemsPerWagonMap?: Record | null; + /** + * PER_TON (bulk) only: the most tons of THIS cargo that may ride one wagon of + * each allowed type, keyed by wagon-type id (e.g. sugar → { NW5: 50 } on a + * 70T wagon). Caps both the wagon count and how much each wagon is loaded, so + * 200T of sugar takes 4 wagons at 50T rather than 3 at 70T. A missing key (or + * a null map) means the wagon's full rated capacity — unlike itemsPerWagonMap + * this is optional, so cargo without a loading limit is unaffected. + */ + @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) + tonsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 691992c54..2e1fd8090 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -68,6 +68,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -96,6 +97,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ]), // Team notifications for the priority-rule approval workflow. NotificationInboxModule, + // Rated wagon capacities — cargo types validate their per-wagon tonnage cap + // against them (a cap above the rating is a typo, not a policy). + WagonTypesModule, ], controllers: [ CargoTypesController, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 34287a023..1f25b0823 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -6,6 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { In } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -13,6 +14,7 @@ import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, @@ -27,6 +29,7 @@ export class CargoTypesService { private readonly repository: ICargoTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepository: IRatesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -75,6 +78,63 @@ export class CargoTypesService { return map; } + /** + * PER_TON (bulk) cargo may cap how many tons ride one wagon, BELOW that + * wagon's rated capacity: sugar at 50T on a 70T wagon means 200T takes 4 + * wagons, not 3. Unlike the PER_ITEM fit this is optional — an absent key + * means the full rated capacity, so existing cargo types are unaffected. + * + * A cap ABOVE the rated capacity is rejected: nobody loads 90T on a 70T + * wagon, so it is a typo, and silently clamping it would leave the config + * screen showing a number the trains never honour. (Allocation clamps too, via + * `bulkTonsPerWagon`, for caps left stale by a later wagon-type edit — this + * check cannot see those, since the cargo type is never re-saved.) + * + * Returns the map trimmed to the allowed wagon types, or null when the cargo + * is not PER_TON / nothing is capped. + */ + private async resolveTonsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + tonsPerWagonMap?: Record | null; + }): Promise | null> { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerTon || !input.wagonTypeIds.length) { + return null; + } + const capped = input.wagonTypeIds.filter( + (id) => input.tonsPerWagonMap?.[id] !== undefined && input.tonsPerWagonMap[id] !== null, + ); + if (!capped.length) return null; + + const wagonTypes = await this.wagonTypesRepository.findAll({ + where: { id: In(capped) }, + }); + const capacityById = new Map( + wagonTypes.map((wt) => [wt.id, Number(wt.capacityTons) || 0]), + ); + + const map: Record = {}; + for (const wagonTypeId of capped) { + const tons = Number(input.tonsPerWagonMap?.[wagonTypeId]); + if (!Number.isFinite(tons) || tons <= 0) { + throw new BadRequestException( + `tonsPerWagonMap for wagon type ${wagonTypeId} must be a number greater than 0`, + ); + } + const capacity = capacityById.get(wagonTypeId); + if (capacity === undefined) { + throw new BadRequestException(`Wagon type ${wagonTypeId} not found`); + } + if (capacity > 0 && tons > capacity) { + throw new BadRequestException( + `Max tons per wagon (${tons}T) exceeds wagon type ${wagonTypeId} rated capacity ${capacity}T`, + ); + } + map[wagonTypeId] = tons; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -90,6 +150,12 @@ export class CargoTypesService { insertAfterId: dto.insertAfterId, }); + const tonsPerWagonMap = await this.resolveTonsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + tonsPerWagonMap: dto.tonsPerWagonMap, + }); + return this.repository.create({ code, cargoTypeName: dto.cargoTypeName, @@ -104,6 +170,7 @@ export class CargoTypesService { wagonTypeIds: dto.wagonTypeIds ?? [], itemsPerWagonMap: dto.itemsPerWagonMap, }), + tonsPerWagonMap, displayOrder, }); } @@ -116,12 +183,32 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + const { + wagonTypeIds, + itemsPerWagonMap, + tonsPerWagonMap, + insertAfterId: _insertAfterId, + ...columns + } = dto; // Re-validate the fit map whenever anything it depends on moves — a partial // update merges with the stored values so e.g. adding a wagon type without // its fit still 400s. Untouched fields leave the stored map alone. const touchesItemsFit = wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + // Same merge rule for the tonnage cap: re-resolve whenever the uom, the + // allowed wagon types, or the caps themselves move, so a wagon type added + // without a cap keeps its full rated capacity and a uom flip drops stale caps. + const touchesTonsCap = + wagonTypeIds !== undefined || tonsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + const resolvedTonsPerWagonMap = touchesTonsCap + ? await this.resolveTonsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + tonsPerWagonMap: + tonsPerWagonMap !== undefined ? tonsPerWagonMap : existing.tonsPerWagonMap, + }) + : undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds @@ -138,6 +225,7 @@ export class CargoTypesService { }), } : {}), + ...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the 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 33730b5ae..44f7c93b5 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 @@ -71,6 +71,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -4100,8 +4101,15 @@ export class BookingBatchService implements OnModuleInit { const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = bookingCargoTons(booking); + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so divide by the cap where one is configured for this type. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + booking.cargoType?.wagonTypes?.[0]?.id, + capacityTons, + ); const byWeight = - cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). @@ -4169,6 +4177,13 @@ export class BookingBatchService implements OnModuleInit { .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) .map((o) => { const wagonTypeId = o.wagonTypeId as string; + // Each type sized on its OWN per-wagon tonnage cap, not just its rating + // — a type capped lower swallows less per wagon. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + wagonTypeId, + o.dims.capacityTons, + ); const wagonsIfAlone = Math.max( 1, bulkItemWagonsRequired( @@ -4176,8 +4191,8 @@ export class BookingBatchService implements OnModuleInit { o.dims.capacityTons, bulkItemsFitFor(booking.cargoType, wagonTypeId), ) || - (o.dims.capacityTons > 0 - ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + (tonsPerWagon > 0 + ? Math.ceil(bookingCargoTons(booking) / tonsPerWagon) : total), ); return { 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 bea01af0a..2769c212b 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,4 +1,4 @@ -import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; +import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n // holds the item count there, not tons. No wagon type is fixed yet, so use // the best count across the cargo's allowed types (per-type items-fit // respected); falls back to `capacity` when the relation isn't loaded. - const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so tonnage divides by that cap, not by raw capacity. + const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity); + if (byWagons > 0) return byWagons; const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } 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 ead3a4e8b..ed476a6e1 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 @@ -4,6 +4,10 @@ import { bookingTrainLengthMeters, bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsForAllowedTypes, + bulkTonWagonsRequired, + bulkWagonsForAllowedTypes, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, @@ -135,6 +139,61 @@ describe('train-capacity.util', () => { }); }); + describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => { + // Sugar is loaded 50T per wagon even on a 70T wagon. + const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } }; + const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons }); + + it('uses the configured cap instead of the rated capacity', () => { + expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50); + }); + + it('falls back to rated capacity when the cargo type caps nothing', () => { + expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70); + }); + + it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => { + // Saved when NW5 was rated 70T; the type was later re-rated to 45T. + expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45); + }); + + it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => { + expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4); + // Same booking, no cap → the old 3-wagon answer. + expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3); + }); + + it('picks the fewest-wagon allowed type, each on its own cap', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + tonsPerWagonMap: { nw5: 50 }, + }; + // NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3. + expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); + }); + + it('routes PER_ITEM and PER_TON through one call', () => { + expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); + // PER_ITEM still wins where an item count is present. + const cars = { + wagonTypes: [{ id: 'nw5', capacityTons: 70 }], + itemsPerWagonMap: { nw5: 4 }, + }; + expect( + bulkWagonsForAllowedTypes( + { freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 }, + cars, + 70, + ), + ).toBe(17); + }); + }); + describe('bookingCargoTons (break-bulk weight preference)', () => { it('prefers bulkTotalWeightTons over the item-count VGM column', () => { expect( 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 867da564b..63feb6e8c 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 @@ -152,8 +152,98 @@ export function bulkItemWagonsRequired( type ItemFitCargoType = { wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; itemsPerWagonMap?: Record | null; + tonsPerWagonMap?: Record | null; } | null; +/** + * Tons of THIS cargo one wagon of this type may carry: the cargo type's + * configured loading limit when set, else the wagon's full rated capacity. + * Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each + * is loaded to 50 — both the count and the fill follow from this one number. + * + * The configured cap is CLAMPED to the rated capacity rather than trusted: the + * cargo-types service rejects a cap above capacity at save time, but a wagon + * type edited DOWN afterwards would leave a stale cap that overloads the wagon. + * Clamping here means no call site can ever load past the physical rating. + */ +export function bulkTonsPerWagon( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const capacity = num(capacityTons); + const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0; + if (!(cap > 0)) return capacity; + return capacity > 0 ? Math.min(cap, capacity) : cap; +} + +/** + * Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo + * type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even + * though the wagon is rated 70T. Returns 0 when there is no tonnage or no + * usable per-wagon figure, so callers can fall back as before. + */ +export function bulkTonWagonsRequired( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const tons = bookingCargoTons(booking); + if (!(perWagon > 0) || !(tons > 0)) return 0; + return Math.max(1, Math.ceil(tons / perWagon)); +} + +/** + * Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon + * types, each sized on its OWN loading limit — the tonnage twin of + * {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single + * wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo + * type has no usable allowed types. + */ +export function bulkTonWagonsForAllowedTypes( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) { + return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons); + } + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkTonWagonsRequired( + booking, + cargoType, + wagonType.id, + wagonType.capacityTons, + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + +/** + * Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM + * sizes by indivisible items, everything else by tonnage under the cargo type's + * per-wagon loading limit. One call so no site has to remember both paths. + */ +export function bulkWagonsForAllowedTypes( + booking: Parameters[0] & { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + return ( + bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) || + bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) + ); +} + /** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ export function bulkItemsFitFor( cargoType: ItemFitCargoType | 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 495feedf0..db8236927 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 @@ -138,6 +138,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -7347,8 +7348,10 @@ export class TrainSchedulingService { ? Math.ceil(booking.wagonsRequired) : 0; const byLength = containerWagonsForLines(booking.bookingContainers ?? []); - const byWeight = - cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon) — more wagons for the same cargo, so more tare to pull. + const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). const byItems = bulkItemWagonsRequired( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0810b3369..ef1e741be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, - bulkItemWagonsForAllowedTypes, + bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { sortBookingsForScheduling, @@ -122,10 +122,11 @@ const shortageFor = ( ? Math.max( 1, // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map - // respected); PER_TON falls through to tonnage over the largest - // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that - // column is the item count, not tons. - bulkItemWagonsForAllowedTypes( + // respected); PER_TON divides by its per-wagon tonnage cap where one + // is configured, else the largest candidate's rating. + // bookingCargoTons, not raw VGM — for PER_ITEM that column is the + // item count, not tons. + bulkWagonsForAllowedTypes( booking, booking.cargoType, Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), 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 ef3089925..9720e46c4 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 @@ -7,6 +7,8 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsRequired, consistViolations, } from './train-capacity.util'; @@ -186,15 +188,29 @@ export function buildBulkWagonPlan( bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + + // PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't + // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs + // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on + // their own cap; only genuinely uncapped tonnage pools at rated capacity. + const cappedTonSlotsByBooking = bookings.map((b, i) => + itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + ? 0 + : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), + ); + const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( bookings.reduce( (sum, b, i) => - itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 + ? sum + : sum + Number(b.cargoTotalWeightVgm ?? 0), 0, ), ); const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; - const slots = Math.max(1, tonSlots + itemSlots); + const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -315,6 +331,7 @@ function allocateBookingsToSlots( // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), + cargoType: booking.cargoType, })); let bookingIndex = 0; @@ -326,8 +343,15 @@ function allocateBookingsToSlots( while (wagonRemaining > 0 && bookingIndex < remaining.length) { const booking = remaining[bookingIndex]; + // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well + // as the wagon count — the plan reserved a wagon per capped chunk, so + // pouring rated capacity into it would leave the last wagon empty. + const takeCap = Math.min( + wagonRemaining, + bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + ); const allocatedWeightTons = roundTons( - Math.min(wagonRemaining, booking.remainingWeightTons), + Math.min(takeCap, booking.remainingWeightTons), ); if (allocatedWeightTons <= 0) { @@ -350,6 +374,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { bookingIndex += 1; + } else if (allocatedWeightTons >= takeCap) { + // The cap stopped this wagon short of its rating and the booking has + // more to load. The leftover room is NOT free: `buildBulkWagonPlan` + // already reserved a wagon for the rest, so backfilling another booking + // here would double-book the consist. Close the wagon. + break; } } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index e37db97cc..57c239885 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -60,12 +60,16 @@ interface CargoNode extends RuleEngineRecord { wagonTypes?: { id: string; code?: string; name?: string }[]; /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */ itemsPerWagonMap?: Record | null; + /** PER_TON only: max tons of this cargo per wagon, keyed by wagon-type id. */ + tonsPerWagonMap?: Record | null; isActive?: boolean; displayOrder?: number; } /** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ const ITEMS_FIT_PREFIX = "itemsFit__"; +/** Form-value prefix for the per-wagon-type tonnage-cap inputs (PER_TON cargo). */ +const TONS_CAP_PREFIX = "tonsCap__"; const str = (v: unknown): string => (v == null ? "" : String(v)); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); @@ -159,8 +163,26 @@ const CargoTypesPage = () => { getInitialValue: (record) => (record as CargoNode).itemsPerWagonMap?.[opt.value], })); + // PER_TON cargo: an OPTIONAL "max tons per wagon" per selected wagon type — + // how much of this commodity actually rides one wagon, which can be less + // than its rating (sugar 50T on a 70T wagon, so 200T takes 4 wagons not 3). + // Left blank the wagon's full rated capacity applies, so existing cargo is + // unaffected; the API rejects a value above the rating. + const tonsCapFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({ + name: `${TONS_CAP_PREFIX}${opt.value}`, + label: `Max tons per ${opt.label} wagon`, + type: "number", + optional: true, + placeholder: "Blank = full wagon capacity", + showIf: (values) => + values.unitOfMeasure === "PER_TON" && + Array.isArray(values.wagonTypeIds) && + (values.wagonTypeIds as string[]).includes(opt.value), + getInitialValue: (record) => + (record as CargoNode).tonsPerWagonMap?.[opt.value], + })); const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds"); - base.splice(wagonTypesAt + 1, 0, ...fitFields); + base.splice(wagonTypesAt + 1, 0, ...fitFields, ...tonsCapFields); return base; }, [wagonTypeOptions]); @@ -230,14 +252,22 @@ const CargoTypesPage = () => { // none are visible (not PER_ITEM) so an update clears stale fits. const payload: Record = {}; const itemsPerWagonMap: Record = {}; + const tonsPerWagonMap: Record = {}; for (const [key, value] of Object.entries(values)) { if (key.startsWith(ITEMS_FIT_PREFIX)) { itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value); + } else if (key.startsWith(TONS_CAP_PREFIX)) { + // Blank means "no cap" (use the full rated capacity), so an empty input + // must stay OUT of the map — sending 0 would be a zero-ton wagon. + if (value !== "" && value !== null && value !== undefined) { + tonsPerWagonMap[key.slice(TONS_CAP_PREFIX.length)] = Number(value); + } } else { payload[key] = value; } } payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null; + payload.tonsPerWagonMap = Object.keys(tonsPerWagonMap).length ? tonsPerWagonMap : null; // Add always attaches to the page we're on; edit keeps the node's parent. if (formMode?.kind === "create" && current) { payload.parentGroupId = current.id;