mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
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:
@@ -13,6 +13,8 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
@@ -563,6 +565,115 @@ export class ContractBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create validation for the shipment form: run the overweight rule + the
|
||||
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
||||
* booking. The portal calls this from the price-confirm modal so the customer
|
||||
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
||||
* un-pairable 20ft set before the booking is created.
|
||||
*/
|
||||
async validateShipment(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<{
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}>;
|
||||
pairingErrors: string[];
|
||||
}> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) return { overweightLines: [], pairingErrors: [] };
|
||||
|
||||
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
||||
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
||||
const resolved = 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 { line, ct, totalVgmTons };
|
||||
}),
|
||||
);
|
||||
|
||||
const ruleResult = await this.ruleEngineService.evaluate({
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
isHazardous: false,
|
||||
isReefer: contract.isReefer ?? false,
|
||||
isGovernment: false,
|
||||
allowConsolidation: false,
|
||||
shippingLineId: null,
|
||||
totalWagons: 0,
|
||||
bulkTons: 0,
|
||||
containers: resolved.map((r) => ({
|
||||
containerTypeId: r.ct.id,
|
||||
quantity: r.line.quantity,
|
||||
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
||||
totalVgmTons: r.totalVgmTons,
|
||||
isReefer: r.ct.isReefer,
|
||||
})),
|
||||
} as never);
|
||||
|
||||
const overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr?.isOverweight) continue;
|
||||
const r = resolved[i];
|
||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||
overweightLines.push({
|
||||
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
|
||||
totalVgmTons: r?.totalVgmTons ?? 0,
|
||||
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
|
||||
excessTons,
|
||||
});
|
||||
}
|
||||
|
||||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||
const twentyFtUnits = resolved
|
||||
.filter((r) => (r.line.containerSize ?? '').includes('20'))
|
||||
.flatMap((r) =>
|
||||
(r.line.units ?? []).map((u, idx) => ({
|
||||
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
|
||||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
);
|
||||
const maxDiff = await this.max20ftPairDiffTons();
|
||||
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
|
||||
(v) => v.message,
|
||||
);
|
||||
|
||||
return { overweightLines, pairingErrors };
|
||||
}
|
||||
|
||||
private async max20ftPairDiffTons(): Promise<number> {
|
||||
const row = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch(() => null);
|
||||
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
|
||||
return Number.isFinite(n) ? n : 10;
|
||||
}
|
||||
|
||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||
private async resolveContainerTypeForSize(
|
||||
size: string,
|
||||
|
||||
@@ -788,6 +788,18 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/validate-shipment')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||
})
|
||||
validateShipment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
) {
|
||||
return this.contractBookingService.validateShipment(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/capacity')
|
||||
@ApiOperation({
|
||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||
|
||||
Reference in New Issue
Block a user