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

@@ -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,
};
}