feat: add per-ton cargo loading limits and update related services

This commit is contained in:
Marshal
2026-08-03 19:16:28 +00:00
parent 9afc281d21
commit 488c2465be
14 changed files with 394 additions and 21 deletions

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`,
);
}
}

View File

@@ -16,7 +16,7 @@ import {
containersPerWagonForSize, containersPerWagonForSize,
wagonsPerUnitForSize, wagonsPerUnitForSize,
} from '../rule-engine/container-type.util'; } 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 { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service'; import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; 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 // Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage. Best // indivisible items instead of pretending the count is tonnage. Best
// count across allowed wagon types, each capped by its items-fit. // count across allowed wagon types, each capped by its items-fit.
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a
if (byItems > 0) return byItems; // 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)); return Math.max(1, Math.ceil(tons / capacity));
} catch { } catch {
return null; return null;

View File

@@ -44,6 +44,19 @@ export class CreateCargoTypeDto {
@IsObject() @IsObject()
itemsPerWagonMap?: Record<string, number> | null; itemsPerWagonMap?: Record<string, number> | null;
@ApiPropertyOptional({
description:
'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' +
'wagon-type id (e.g. { "<nw5-id>": 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<string, number> | null;
@ApiPropertyOptional({ default: false }) @ApiPropertyOptional({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -60,6 +60,17 @@ export class CargoType extends BaseEntity {
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
itemsPerWagonMap?: Record<string, number> | null; itemsPerWagonMap?: Record<string, number> | 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<string, number> | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false }) @Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean; requiresDirectorApproval!: boolean;

View File

@@ -68,6 +68,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service'; import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; 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 { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.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. // Team notifications for the priority-rule approval workflow.
NotificationInboxModule, 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: [ controllers: [
CargoTypesController, CargoTypesController,

View File

@@ -6,6 +6,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { In } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util'; import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.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 { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity'; import { CargoType } from '../entities/cargo-type.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { import {
CARGO_TYPES_REPOSITORY, CARGO_TYPES_REPOSITORY,
ICargoTypesRepository, ICargoTypesRepository,
@@ -27,6 +29,7 @@ export class CargoTypesService {
private readonly repository: ICargoTypesRepository, private readonly repository: ICargoTypesRepository,
@Inject(RATES_REPOSITORY) @Inject(RATES_REPOSITORY)
private readonly ratesRepository: IRatesRepository, private readonly ratesRepository: IRatesRepository,
private readonly wagonTypesRepository: WagonTypesRepository,
private readonly displayOrder: DisplayOrderService, private readonly displayOrder: DisplayOrderService,
) {} ) {}
@@ -75,6 +78,63 @@ export class CargoTypesService {
return map; 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<string, number> | null;
}): Promise<Record<string, number> | 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<string, number> = {};
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. */ /** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> { async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName); const code = generateCode(dto.cargoTypeName);
@@ -90,6 +150,12 @@ export class CargoTypesService {
insertAfterId: dto.insertAfterId, insertAfterId: dto.insertAfterId,
}); });
const tonsPerWagonMap = await this.resolveTonsPerWagonMap({
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeIds: dto.wagonTypeIds ?? [],
tonsPerWagonMap: dto.tonsPerWagonMap,
});
return this.repository.create({ return this.repository.create({
code, code,
cargoTypeName: dto.cargoTypeName, cargoTypeName: dto.cargoTypeName,
@@ -104,6 +170,7 @@ export class CargoTypesService {
wagonTypeIds: dto.wagonTypeIds ?? [], wagonTypeIds: dto.wagonTypeIds ?? [],
itemsPerWagonMap: dto.itemsPerWagonMap, itemsPerWagonMap: dto.itemsPerWagonMap,
}), }),
tonsPerWagonMap,
displayOrder, displayOrder,
}); });
} }
@@ -116,12 +183,32 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId); const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); 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 // 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 // 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. // its fit still 400s. Untouched fields leave the stored map alone.
const touchesItemsFit = const touchesItemsFit =
wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; 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, { const updated = await this.repository.update(id, {
...columns, ...columns,
...(wagonTypeIds ...(wagonTypeIds
@@ -138,6 +225,7 @@ export class CargoTypesService {
}), }),
} }
: {}), : {}),
...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}),
}); });
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
// A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the

View File

@@ -71,6 +71,7 @@ import {
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor, bulkItemsFitFor,
bulkItemWagonsRequired, bulkItemWagonsRequired,
bulkTonsPerWagon,
bookingGrossWeightTons, bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive, deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons, sizePartialOfferWagons,
@@ -4100,8 +4101,15 @@ export class BookingBatchService implements OnModuleInit {
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = bookingCargoTons(booking); 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 = 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 // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity). // 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) .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => { .map((o) => {
const wagonTypeId = o.wagonTypeId as string; 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( const wagonsIfAlone = Math.max(
1, 1,
bulkItemWagonsRequired( bulkItemWagonsRequired(
@@ -4176,8 +4191,8 @@ export class BookingBatchService implements OnModuleInit {
o.dims.capacityTons, o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId), bulkItemsFitFor(booking.cargoType, wagonTypeId),
) || ) ||
(o.dims.capacityTons > 0 (tonsPerWagon > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) ? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
: total), : total),
); );
return { return {

View File

@@ -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 { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { 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 // 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 // the best count across the cargo's allowed types (per-type items-fit
// respected); falls back to `capacity` when the relation isn't loaded. // respected); falls back to `capacity` when the relation isn't loaded.
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
if (byItems > 0) return byItems; // 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); const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity)); return Math.max(1, Math.ceil(weight / capacity));
} }

View File

@@ -4,6 +4,10 @@ import {
bookingTrainLengthMeters, bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes, bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired, bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsForAllowedTypes,
bulkTonWagonsRequired,
bulkWagonsForAllowedTypes,
consistUsage, consistUsage,
consistViolations, consistViolations,
deriveTrainCapacityFromLocomotive, 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)', () => { describe('bookingCargoTons (break-bulk weight preference)', () => {
it('prefers bulkTotalWeightTons over the item-count VGM column', () => { it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
expect( expect(

View File

@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
type ItemFitCargoType = { type ItemFitCargoType = {
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
itemsPerWagonMap?: Record<string, number> | null; itemsPerWagonMap?: Record<string, number> | null;
tonsPerWagonMap?: Record<string, number> | null;
} | 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<typeof bookingCargoTons>[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<typeof bookingCargoTons>[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<typeof bookingCargoTons>[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. */ /** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
export function bulkItemsFitFor( export function bulkItemsFitFor(
cargoType: ItemFitCargoType | undefined, cargoType: ItemFitCargoType | undefined,

View File

@@ -138,6 +138,7 @@ import {
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor, bulkItemsFitFor,
bulkItemWagonsRequired, bulkItemWagonsRequired,
bulkTonsPerWagon,
deriveTrainCapacityFromLocomotive, deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits, combinedLocomotiveLimits,
trainSetLocomotiveLimits, trainSetLocomotiveLimits,
@@ -7347,8 +7348,10 @@ export class TrainSchedulingService {
? Math.ceil(booking.wagonsRequired) ? Math.ceil(booking.wagonsRequired)
: 0; : 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight = // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; // 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 // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
// tonnage suggests — their tare must be pulled too (batch dimsFor parity). // tonnage suggests — their tare must be pulled too (batch dimsFor parity).
const byItems = bulkItemWagonsRequired( const byItems = bulkItemWagonsRequired(

View File

@@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { import {
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor, bulkItemsFitFor,
bulkItemWagonsForAllowedTypes, bulkWagonsForAllowedTypes,
} from './train-capacity.util'; } from './train-capacity.util';
import { import {
sortBookingsForScheduling, sortBookingsForScheduling,
@@ -122,10 +122,11 @@ const shortageFor = (
? Math.max( ? Math.max(
1, 1,
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
// respected); PER_TON falls through to tonnage over the largest // respected); PER_TON divides by its per-wagon tonnage cap where one
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that // is configured, else the largest candidate's rating.
// column is the item count, not tons. // bookingCargoTons, not raw VGM — for PER_ITEM that column is the
bulkItemWagonsForAllowedTypes( // item count, not tons.
bulkWagonsForAllowedTypes(
booking, booking,
booking.cargoType, booking.cargoType,
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),

View File

@@ -7,6 +7,8 @@ import {
bookingCargoTons, bookingCargoTons,
bulkItemsFitFor, bulkItemsFitFor,
bulkItemWagonsRequired, bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsRequired,
consistViolations, consistViolations,
} from './train-capacity.util'; } from './train-capacity.util';
@@ -186,15 +188,29 @@ export function buildBulkWagonPlan(
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
); );
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); 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( const totalWeight = roundTons(
bookings.reduce( bookings.reduce(
(sum, b, i) => (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, 0,
), ),
); );
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 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) => ({ const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1, sequenceNo: index + 1,
@@ -315,6 +331,7 @@ function allocateBookingsToSlots(
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
// bookings that column is an item COUNT, not tons. // bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)), remainingWeightTons: roundTons(bookingCargoTons(booking)),
cargoType: booking.cargoType,
})); }));
let bookingIndex = 0; let bookingIndex = 0;
@@ -326,8 +343,15 @@ function allocateBookingsToSlots(
while (wagonRemaining > 0 && bookingIndex < remaining.length) { while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex]; 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( const allocatedWeightTons = roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons), Math.min(takeCap, booking.remainingWeightTons),
); );
if (allocatedWeightTons <= 0) { if (allocatedWeightTons <= 0) {
@@ -350,6 +374,12 @@ function allocateBookingsToSlots(
if (booking.remainingWeightTons <= 0) { if (booking.remainingWeightTons <= 0) {
bookingIndex += 1; 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;
} }
} }

View File

@@ -60,12 +60,16 @@ interface CargoNode extends RuleEngineRecord {
wagonTypes?: { id: string; code?: string; name?: string }[]; wagonTypes?: { id: string; code?: string; name?: string }[];
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */ /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
itemsPerWagonMap?: Record<string, number> | null; itemsPerWagonMap?: Record<string, number> | null;
/** PER_TON only: max tons of this cargo per wagon, keyed by wagon-type id. */
tonsPerWagonMap?: Record<string, number> | null;
isActive?: boolean; isActive?: boolean;
displayOrder?: number; displayOrder?: number;
} }
/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ /** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
const ITEMS_FIT_PREFIX = "itemsFit__"; 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 str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
@@ -159,8 +163,26 @@ const CargoTypesPage = () => {
getInitialValue: (record) => getInitialValue: (record) =>
(record as CargoNode).itemsPerWagonMap?.[opt.value], (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"); const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
base.splice(wagonTypesAt + 1, 0, ...fitFields); base.splice(wagonTypesAt + 1, 0, ...fitFields, ...tonsCapFields);
return base; return base;
}, [wagonTypeOptions]); }, [wagonTypeOptions]);
@@ -230,14 +252,22 @@ const CargoTypesPage = () => {
// none are visible (not PER_ITEM) so an update clears stale fits. // none are visible (not PER_ITEM) so an update clears stale fits.
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
const itemsPerWagonMap: Record<string, number> = {}; const itemsPerWagonMap: Record<string, number> = {};
const tonsPerWagonMap: Record<string, number> = {};
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
if (key.startsWith(ITEMS_FIT_PREFIX)) { if (key.startsWith(ITEMS_FIT_PREFIX)) {
itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value); 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 { } else {
payload[key] = value; payload[key] = value;
} }
} }
payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null; 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. // Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) { if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id; payload.parentGroupId = current.id;