add hard capacity ceiling to weight limit rules

This commit is contained in:
Marshal
2026-07-04 01:11:23 +00:00
parent 97cc9d76b1
commit 8ea2c8e95a
19 changed files with 340 additions and 196 deletions

View File

@@ -1077,7 +1077,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
bookingContainers: { containerType: true, units: true },
cargoType: true,
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },

View File

@@ -133,6 +133,13 @@ export class ContractBookingService {
});
}
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
@@ -608,6 +615,7 @@ export class ContractBookingService {
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
@@ -621,6 +629,7 @@ export class ContractBookingService {
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
capacityErrors: [],
lineItems: [],
totalAmount: 0,
};
@@ -695,16 +704,63 @@ export class ContractBookingService {
(v) => v.message,
);
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
@@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto {
@Min(0)
@Transform(({ value }) => Number(value))
maxVgmTons!: number;
@ApiPropertyOptional({
description:
'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value)))
maxCapacityTons?: number | null;
}

View File

@@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity {
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number;
/**
* Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or
* below this is "overweight" (surcharge + warning); weight above this hard-
* blocks booking creation entirely. Null = no ceiling (overweight only).
*/
@Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxCapacityTons!: number | null;
}

View File

@@ -136,6 +136,10 @@ export class RuleEngineService {
}
}
hardBlocked.push(
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -296,6 +300,40 @@ export class RuleEngineService {
};
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
* must not be created at all. Overweight (above maxVgmTons but within
* capacity) is NOT reported here — that is a surcharge, not a block.
*/
async capacityViolations(
containers: Array<{
containerTypeId: string;
quantity: number;
totalVgmTons: number;
}>,
tradeDirection: string,
): Promise<string[]> {
const violations: string[] = [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
tradeDirection,
);
const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity;
if (container.totalVgmTons > maxTotal) {
const label = rule.containerType?.code ?? container.containerTypeId;
violations.push(
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
);
}
}
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/

View File

@@ -1,4 +1,10 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -62,13 +68,31 @@ export class WeightLimitRulesService {
}
}
/**
* Capacity is the hard ceiling; the VGM limit is the soft overweight
* threshold. A ceiling below the threshold would make every overweight
* booking impossible to create, which is never what the operator means.
*/
private assertCapacityAboveVgmLimit(
maxVgmTons: number,
maxCapacityTons: number | null | undefined,
): void {
if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) {
throw new BadRequestException(
'Max capacity must be greater than or equal to the max VGM limit.',
);
}
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons);
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons,
maxCapacityTons: dto.maxCapacityTons ?? null,
});
}
@@ -79,6 +103,12 @@ export class WeightLimitRulesService {
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons;
this.assertCapacityAboveVgmLimit(
patch.maxVgmTons ?? Number(existing.maxVgmTons),
patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons,
);
// Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {

View File

@@ -605,16 +605,22 @@ export class TrainSchedulingService {
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: 'No bookings fit on available fleet wagons',
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
@@ -628,12 +634,14 @@ export class TrainSchedulingService {
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (limitLoco.maxPullWeightTons < totalWeightTons) {
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case.
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
@@ -2251,9 +2259,16 @@ export class TrainSchedulingService {
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
if (resolvedMode === 'MIXED') {
violations.push(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
pushLimit(
validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
);
if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
@@ -2270,7 +2285,7 @@ export class TrainSchedulingService {
);
}
} else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits));
pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push(
@@ -2293,8 +2308,8 @@ export class TrainSchedulingService {
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) {
violations.push(message);
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
}
@@ -2321,9 +2336,9 @@ export class TrainSchedulingService {
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
);
]);
}
} else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({
@@ -2341,7 +2356,7 @@ export class TrainSchedulingService {
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No locomotive can support the total train weight and length');
pushLimit(['No locomotive can support the total train weight and length']);
}
}
@@ -3740,7 +3755,7 @@ export class TrainSchedulingService {
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});

View File

@@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
@@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons),
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
containerNumber: line.containerNumber ?? null,
containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
});
}
}