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

@@ -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