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

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