mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: add per-ton cargo loading limits and update related services
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user