Implement intercity document handling and rejection notes for contracts

This commit is contained in:
Marshal
2026-07-21 10:39:21 +00:00
parent 6f8456631c
commit 9ca4075c1e
3 changed files with 121 additions and 60 deletions

View File

@@ -137,7 +137,7 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = []; const lineItems: PriceLineItemDto[] = [];
let total = 0; let total = 0;
const { lineItems: baseLines, usedRates: baseRates } = const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) { for (const line of baseLines) {
lineItems.push(line); lineItems.push(line);
@@ -247,7 +247,7 @@ export class BookingPricingService {
usedRates: [...usedRatesMap.values()], usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers, appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore, priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings, warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: ruleResult.hardBlocked, hardBlocked: ruleResult.hardBlocked,
overweightLines, overweightLines,
}; };
@@ -454,7 +454,7 @@ export class BookingPricingService {
booking: Booking, booking: Booking,
evalInput: BookingEvaluationInput, evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null, frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
const liveRates = await this.ratesService.findLiveRates(); const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency; const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB'; const isEtbBooking = paymentCurrency === 'ETB';
@@ -476,6 +476,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = []; const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>(); const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const wagonCount = await this.resolveWagonCount(booking); const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) { for (const container of evalInput.containers) {
@@ -487,47 +488,63 @@ export class BookingPricingService {
booking.originYardId, booking.originYardId,
booking.destinationYardId, booking.destinationYardId,
); );
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const unitUsd = Number(rate.rateValue);
// H15: frozen contract rate for this container size, when present — its // H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert). // unitPrice is already in the booking currency (no USD→currency convert).
// It also stands on its own: a contract line prices off the agreed rate
// even when nobody configured a live rate for this leg + type yet.
const frozen = await this.frozenRateForContainer( const frozen = await this.frozenRateForContainer(
frozenRates, frozenRates,
container.containerTypeId, container.containerTypeId,
paymentCurrency, paymentCurrency,
); );
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate — an unpriced line with a warning is recoverable; a
// silently mischarged one is not.
warnings.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
'the line was not priced.',
);
continue;
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
let amount: number; let amount: number;
let unitAmount: number; let unitAmount: number;
if (frozen) { if (frozen) {
unitAmount = Number(frozen.unitPrice); unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit( amount = this.amountForUnit(
rate.rateUnit, rateUnit,
unitAmount, unitAmount,
container.quantity, container.quantity,
wagonCount, wagonCount,
); );
} else { } else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
} }
const label = await this.containerTypeLabel(container.containerTypeId); if (rate) usedRatesMap.set(rate.id, rate);
lines.push({ lines.push({
code: rateType, code: rateType,
description: `${label} rail freight`, description: `${label} rail freight`,
amount, amount,
unitAmount, unitAmount,
unit: rate.rateUnit, unit: rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
currency: paymentCurrency, currency: paymentCurrency,
}); });
} }
if (lines.length === 0) { if (lines.length === 0 && evalInput.containers.length === 0) {
// Bulk (and any booking with no container lines) still has to price off a // Bulk (and any booking with no container lines) still has to price off a
// rate configured for this leg — never one belonging to another route. // rate configured for this leg — never one belonging to another route.
// Container bookings never reach this fallback: their lines price per
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find( const fallback = liveRates.find(
(r) => (r) =>
r.rateType === rateType && r.rateType === rateType &&
@@ -573,7 +590,7 @@ export class BookingPricingService {
} }
} }
return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
} }
/** /**

View File

@@ -301,48 +301,57 @@ export class ContractBookingService {
} as never), } as never),
); );
// Persist container lines + per-unit container numbers (container freight only). // Everything between the insert and the priced update must be all-or-nothing:
if (freightType === 'CONTAINER') { // a throw part-way (container persist, weight rules, pricing) would otherwise
await this.persistContainers(booking.id, contract, dto); // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
} // occupies the one-time contract's single active-booking slot until the
// doc-review sweep expires it — and the clearance cycle still points at the
// Reload with containers to compute the total from contract unit rates × qty. // previous booking, so the hub keeps offering "Rebook" against a dead draft.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); try {
if (loaded) { // Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') { if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded); await this.persistContainers(booking.id, contract, dto);
} }
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// Reject a zero-price booking outright. A total of 0 means no contract rate // Reload with containers to compute the total from contract unit rates × qty.
// matched the route/container (or the rate is unset), so the booking is not const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
// valid to ship or invoice. Roll back the just-inserted row + its lines so it if (loaded) {
// does NOT occupy the one-time contract's single active-booking slot — else if (freightType === 'CONTAINER') {
// the customer's retry hits "already has an active booking" against a broken await this.applyWeightResults(loaded);
// draft. The customer must fix the contract's rates, then rebook. }
if (!(computed.totalAmount > 0)) { const computed = await this.bookingPricingService.computePriceForBooking(loaded);
await this.bookingsRepository.deleteContainers(booking.id); // Reject a zero-price booking outright. A total of 0 means no contract rate
await this.bookingsRepository.hardDelete(booking.id); // matched the route/container (or the rate is unset), so the booking is not
throw new BadRequestException( // valid to ship or invoice. The catch below rolls back the row + its lines.
'Booking price came out as 0 — no contract rate matches this ' + if (!(computed.totalAmount > 0)) {
'route/cargo. Set the contract rate and try again.', throw new BadRequestException(
); 'Booking price came out as 0 — no contract rate matches this ' +
} 'route/cargo. Set the contract rate and try again.',
await this.bookingsRepository.update(booking.id, { );
totalAmount: computed.totalAmount, }
priorityScore: computed.priorityScore, await this.bookingsRepository.update(booking.id, {
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
currency: computed.currency, priorityScore: computed.priorityScore,
generatedAt: new Date().toISOString(), pricingBreakdown: {
}, lineItems: computed.lineItems,
} as never); totalAmount: computed.totalAmount,
await this.bookingPricingService.createPricingSnapshots( currency: computed.currency,
booking.id, generatedAt: new Date().toISOString(),
computed.usedRates, },
computed.appliedModifiers, } as never);
); await this.bookingPricingService.createPricingSnapshots(
warnings.push(...computed.warnings); booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
} catch (err) {
await this.bookingsRepository
.deleteContainers(booking.id)
.catch(() => undefined);
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
throw err;
} }
// Wagon consolidation gate. A container drawdown whose lines leave a partial // Wagon consolidation gate. A container drawdown whose lines leave a partial
@@ -1600,17 +1609,23 @@ export class ContractBookingService {
throw new BadRequestException('At least one container line is required.'); throw new BadRequestException('At least one container line is required.');
} }
const allowedSizes = new Set( // Size strings arrive in mixed formats ("20ft" from the contract scope,
// bare "20" from the rebook seed) — compare numerically so format never
// fails a size that IS in scope.
const allowedSizesFt = new Set(
(contract.cargoScope ?? []) (contract.cargoScope ?? [])
.map((c) => c.containerSize) .map((c) => parseInt(c.containerSize ?? '', 10))
.filter((s): s is string => !!s), .filter((n) => Number.isFinite(n)),
); );
const containerRepo = this.dataSource.getRepository(BookingContainer); const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) { for (const line of lines) {
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { if (
allowedSizesFt.size &&
!allowedSizesFt.has(parseInt(line.containerSize, 10))
) {
throw new BadRequestException( throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`, `Container size ${line.containerSize} is outside the contract scope.`,
); );
@@ -1747,6 +1762,24 @@ export class ContractBookingService {
}), }),
); );
// Same size-scope gate persistContainers enforces at create, surfaced as a
// blocking preview error so the form can't confirm a size the contract does
// not cover. Numeric compare — "20" and "20ft" are the same size.
const allowedSizesFt = new Set(
(contract.cargoScope ?? [])
.map((c) => parseInt(c.containerSize ?? '', 10))
.filter((n) => Number.isFinite(n)),
);
const scopeErrors = allowedSizesFt.size
? [
...new Set(
lines
.map((l) => l.containerSize)
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
),
].map((s) => `Container size ${s} is outside the contract scope.`)
: [];
// The unsaved twin of the booking createUnderContract would write: same // The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the // denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines. // pricing service derives wagon counts from the in-memory lines.
@@ -1859,7 +1892,7 @@ export class ContractBookingService {
overweightSurchargeAmount, overweightSurchargeAmount,
currency: computed.currency, currency: computed.currency,
pairingErrors, pairingErrors,
capacityErrors, capacityErrors: [...scopeErrors, ...capacityErrors],
containerClashErrors, containerClashErrors,
spaceErrors, spaceErrors,
lineItems: computed.lineItems, lineItems: computed.lineItems,

View File

@@ -447,6 +447,17 @@ export default function GlCreateBookingForm() {
if (!copyFromBooking || prefilled) return; if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? []; const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return; if (!lines.length) return;
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
// form entry would send.
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
if (sizeFt == null) return "";
return (
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
`${sizeFt}ft`
);
};
setPrefilled(true); setPrefilled(true);
setContainerLines( setContainerLines(
lines.map((c) => { lines.map((c) => {
@@ -466,7 +477,7 @@ export default function GlCreateBookingForm() {
})) }))
: Array.from({ length: qty }, emptyUnit); : Array.from({ length: qty }, emptyUnit);
return { return {
containerSize: String(c.containerType?.sizeFt ?? ""), containerSize: scopeSizeForFt(c.containerType?.sizeFt),
quantity: String(qty), quantity: String(qty),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length), hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length), reeferQuantity: String(units.filter((u) => u.isReefer).length),
@@ -475,7 +486,7 @@ export default function GlCreateBookingForm() {
}; };
}), }),
); );
}, [copyFromBooking, prefilled]); }, [copyFromBooking, prefilled, containerSizes]);
// Seed one shipment line per contracted size exactly once — same seeding the // Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines. // portal form does. Subsequent renders reuse the lines.