feat: implement 20ft container weight-pairing validation

- Added ContainerValidationService to handle 20ft weight-pairing logic.
- Introduced validate20ftWeightPairing utility function to check weight differences.
- Updated BookingPricingService to include overweight line details and pairing errors in price response.
- Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers.
- Created ShipmentValidation interface for pre-submit validation of container contracts.
- Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors.
- Updated front-end components to display validation results and prevent submission when errors are present.
This commit is contained in:
Marshal
2026-07-03 09:26:27 +00:00
parent d832e83b4a
commit 35cb20da0b
20 changed files with 646 additions and 5 deletions

View File

@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
ratesService as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
});

View File

@@ -17,6 +17,14 @@ import {
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service';
export interface OverweightLine {
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
priorityScore: number;
warnings: string[];
hardBlocked: string[];
overweightLines: OverweightLine[];
}
type StoredPricingBreakdown = {
@@ -67,6 +76,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -94,12 +104,19 @@ export class BookingPricingService {
},
} as never);
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
// (and the overweight warning + surcharge) at the confirm step, before submit.
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
const pairing = await this.containerValidationService.validate20ftPairing(booking);
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors: pairing.map((p) => p.message),
};
}
@@ -169,6 +186,35 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
}
// Overweight detail for the customer: map the engine's per-line results back
// to the booking's container lines (same order) for code + weights. maxAllowed
// is derived from the line total minus the excess the engine computed.
const overweightLines: OverweightLine[] = [];
const containerLines = (booking.bookingContainers ?? []).filter(
(bc) => bc.containerTypeId != null,
);
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const line = containerLines[i];
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
const excessTons = Number(wr.overweightExcessTons ?? 0);
let code = line?.containerSize ?? '';
if (line?.containerTypeId) {
try {
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
} catch {
// fall back to the container size label
}
}
overweightLines.push({
containerTypeCode: code,
totalVgmTons,
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
excessTons,
});
}
return {
lineItems,
totalAmount: total,
@@ -178,6 +224,7 @@ export class BookingPricingService {
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
overweightLines,
};
}

View File

@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, ruleEngineService };
}

View File

@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, filesService };
}

View File

@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
@@ -51,6 +52,7 @@ export class BookingTransitionService {
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
) {}
@@ -58,6 +60,19 @@ export class BookingTransitionService {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> {
const violations =
await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) {
throw new BadRequestException(
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
@@ -78,6 +93,11 @@ export class BookingTransitionService {
requiresDirectorApproval: false,
});
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
// If no balanced pairing exists the booking cannot proceed (overweight only
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
await this.assert20ftPairable(booking);
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
@@ -158,6 +178,7 @@ export class BookingTransitionService {
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.assert20ftPairable(booking);
await this.pricingService.createPricingSnapshots(
bookingId,

View File

@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -78,6 +79,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
BookingsService,
BookingsRepository,
ConsolidationService,
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingTransitionService,

View File

@@ -0,0 +1,55 @@
import { validate20ftWeightPairing } from './container-pairing.util';
describe('validate20ftWeightPairing', () => {
const MAX_DIFF = 10;
it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
// sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
const units = [
{ label: 'A', grossWeightTons: 24 },
{ label: 'B', grossWeightTons: 8 },
{ label: 'C', grossWeightTons: 18 },
{ label: 'D', grossWeightTons: 15 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('flags a pair whose weight difference exceeds the cap', () => {
// sorted: 5, 25 → single pair diff 20 > 10.
const units = [
{ label: 'HEAVY', grossWeightTons: 25 },
{ label: 'LIGHT', grossWeightTons: 5 },
];
const result = validate20ftWeightPairing(units, MAX_DIFF);
expect(result).toHaveLength(1);
expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
expect(result[0].diffTons).toBe(20);
});
it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
// sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
const units = [
{ label: 'A', grossWeightTons: 10 },
{ label: 'B', grossWeightTons: 12 },
{ label: 'C', grossWeightTons: 30 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
// Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
// but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
const units = [
{ label: 'A', grossWeightTons: 20 },
{ label: 'B', grossWeightTons: 12 },
{ label: 'C', grossWeightTons: 22 },
{ label: 'D', grossWeightTons: 10 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('returns nothing for fewer than two units', () => {
expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Booking-time 20ft weight-pairing rule.
*
* A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
* same wagon their gross-weight difference must not exceed `maxPairDiffTons`
* (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
* balanced. 40ft containers occupy a whole wagon alone and never pair.
*
* At booking time the customer enters every 20ft container's weight but not its
* wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
* (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
* valid pairing exists this one finds it — a violation here means no balanced
* pairing is possible and the booking must be blocked. An odd leftover 20ft is
* fine: it has no partner in this booking and flows to consolidation.
*/
export interface Container20ftUnit {
/** Human label for messages, e.g. the container number. */
label: string;
grossWeightTons: number;
}
export interface PairingViolation {
message: string;
/** The two container labels whose pairing exceeds the diff cap. */
labels: [string, string];
diffTons: number;
}
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Validate that the given 20ft units can all be paired onto wagons within the
* weight-difference cap. Returns one violation per over-cap adjacent pair (empty
* when every wagon pair is balanced or there is nothing to pair). A single
* leftover unit (odd count) is not a violation.
*/
export function validate20ftWeightPairing(
units: Container20ftUnit[],
maxPairDiffTons: number,
): PairingViolation[] {
if (units.length < 2 || maxPairDiffTons == null) return [];
// Ascending by weight: adjacent pairs have the smallest possible diffs.
const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
const violations: PairingViolation[] = [];
for (let i = 0; i + 1 < sorted.length; i += 2) {
const a = sorted[i];
const b = sorted[i + 1];
const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
if (diff > maxPairDiffTons) {
violations.push({
message:
`20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
`${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
`weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
labels: [a.label, b.label],
diffTons: round2(diff),
});
}
}
return violations;
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import {
Container20ftUnit,
PairingViolation,
validate20ftWeightPairing,
} from './container-pairing.util';
/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
/**
* Booking-time container validations that need the customer-entered per-unit
* weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
* rule engine (which works on line totals) because pairing is per physical unit.
*/
@Injectable()
export class ContainerValidationService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
private async maxPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ order: { createdAt: 'ASC' }, take: 1 })
.then((rows) => rows[0] ?? null)
.catch(() => null);
const v = row?.max20ftPairWeightDiffTons;
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
}
/** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
private async load20ftUnits(booking: Booking): Promise<Container20ftUnit[]> {
const lines = (booking.bookingContainers ?? []).filter(
(bc) => (bc.containerSize ?? '').includes('20'),
);
if (!lines.length) return [];
const units = await this.dataSource
.getRepository(BookingContainerUnit)
.find({
where: { bookingContainerId: In(lines.map((l) => l.id)) },
order: { sortOrder: 'ASC' },
});
return units.map((u) => ({
label: u.containerNumber || u.id.slice(0, 8),
grossWeightTons: Number(u.vgmTons ?? 0),
}));
}
/**
* Validate the 20ft weight-pairing rule for a booking. Returns one message per
* pair whose weight difference exceeds the cap; empty when all 20ft can be
* balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
* it flows to consolidation. Callers hard-block a non-empty result.
*/
async validate20ftPairing(booking: Booking): Promise<PairingViolation[]> {
// Only bookings whose 20ft lines actually carry per-unit weights can be
// checked; contract-drawdown bookings do (units are required there).
const containerLines = booking.bookingContainers ?? [];
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return [];
const units = await this.load20ftUnits(booking);
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff);
}
}

View File

@@ -27,6 +27,20 @@ export class PriceLineItemDto {
currency!: string;
}
export class OverweightLineDto {
@ApiProperty()
containerTypeCode!: string;
@ApiProperty()
totalVgmTons!: number;
@ApiProperty()
maxAllowedTons!: number;
@ApiProperty()
excessTons!: number;
}
export class GeneratePriceResponseDto {
@ApiProperty()
bookingId!: string;
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
@ApiProperty({ type: [String] })
warnings!: string[];
/** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
@ApiProperty({ type: [OverweightLineDto] })
overweightLines!: OverweightLineDto[];
/**
* 20ft weight-pairing violations. Non-empty means the booking cannot be
* balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
* container weights/quantities. (Overweight, by contrast, only warns.)
*/
@ApiProperty({ type: [String] })
pairingErrors!: string[];
}