mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
add hard capacity ceiling to weight limit rules
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a hard per-unit weight ceiling to weight limit rules.
|
||||
*
|
||||
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
|
||||
* max_capacity_tons is the absolute ceiling above which a booking cannot be
|
||||
* created at all. Null means no ceiling (existing behavior).
|
||||
*/
|
||||
export class AddMaxCapacityToWeightLimitRules1930000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddMaxCapacityToWeightLimitRules1930000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
DROP COLUMN IF EXISTS max_capacity_tons;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user