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,
|
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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user