mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add per-ton cargo loading limits and update related services
This commit is contained in:
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +44,19 @@ export class CreateCargoTypeDto {
|
||||
@IsObject()
|
||||
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 })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -60,6 +60,17 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
|
||||
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 })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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. */
|
||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
|
||||
type ItemFitCargoType = {
|
||||
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
tonsPerWagonMap?: Record<string, number> | 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. */
|
||||
export function bulkItemsFitFor(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
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<string, unknown> = {};
|
||||
const itemsPerWagonMap: Record<string, number> = {};
|
||||
const tonsPerWagonMap: Record<string, number> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user