From 9ca4075c1ed738017afc1230aeffe28542dc15ab Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 21 Jul 2026 10:39:21 +0000 Subject: [PATCH] Implement intercity document handling and rejection notes for contracts --- .../bookings/booking-pricing.service.ts | 45 +++++-- .../contracts/contract-booking.service.ts | 121 +++++++++++------- .../contracts/GlCreateBookingForm.tsx | 15 ++- 3 files changed, 121 insertions(+), 60 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index cbb630794..a42d23859 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -137,7 +137,7 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates } = + const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); @@ -247,7 +247,7 @@ export class BookingPricingService { usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, - warnings: ruleResult.warnings, + warnings: [...ruleResult.warnings, ...baseWarnings], hardBlocked: ruleResult.hardBlocked, overweightLines, }; @@ -454,7 +454,7 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -476,6 +476,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); + const warnings: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { @@ -487,47 +488,63 @@ export class BookingPricingService { booking.originYardId, 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 // 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( frozenRates, container.containerTypeId, 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 unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( - rate.rateUnit, + rateUnit, unitAmount, container.quantity, wagonCount, ); } 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; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } - const label = await this.containerTypeLabel(container.containerTypeId); + if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount, - unit: rate.rateUnit, - quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), + unit: rateUnit, + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), 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 // 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( (r) => r.rateType === rateType && @@ -573,7 +590,7 @@ export class BookingPricingService { } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3c3f81461..df930d805 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -301,48 +301,57 @@ export class ContractBookingService { } as never), ); - // Persist container lines + per-unit container numbers (container freight only). - if (freightType === 'CONTAINER') { - await this.persistContainers(booking.id, contract, dto); - } - - // Reload with containers to compute the total from contract unit rates × qty. - const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); - if (loaded) { + // Everything between the insert and the priced update must be all-or-nothing: + // a throw part-way (container persist, weight rules, pricing) would otherwise + // 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 + // previous booking, so the hub keeps offering "Rebook" against a dead draft. + try { + // Persist container lines + per-unit container numbers (container freight only). 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 - // matched the route/container (or the rate is unset), so the booking is not - // valid to ship or invoice. Roll back the just-inserted row + its lines so it - // does NOT occupy the one-time contract's single active-booking slot — else - // the customer's retry hits "already has an active booking" against a broken - // draft. The customer must fix the contract's rates, then rebook. - if (!(computed.totalAmount > 0)) { - await this.bookingsRepository.deleteContainers(booking.id); - await this.bookingsRepository.hardDelete(booking.id); - 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, - pricingBreakdown: { - lineItems: computed.lineItems, + + // Reload with containers to compute the total from contract unit rates × qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. The catch below rolls back the row + its lines. + if (!(computed.totalAmount > 0)) { + 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, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - await this.bookingPricingService.createPricingSnapshots( - booking.id, - computed.usedRates, - computed.appliedModifiers, - ); - warnings.push(...computed.warnings); + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + 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 @@ -1600,17 +1609,23 @@ export class ContractBookingService { 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 ?? []) - .map((c) => c.containerSize) - .filter((s): s is string => !!s), + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { - if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + if ( + allowedSizesFt.size && + !allowedSizesFt.has(parseInt(line.containerSize, 10)) + ) { throw new BadRequestException( `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 // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. @@ -1859,7 +1892,7 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors, + capacityErrors: [...scopeErrors, ...capacityErrors], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 97199afc3..bc9521dd3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -447,6 +447,17 @@ export default function GlCreateBookingForm() { if (!copyFromBooking || prefilled) return; const lines = copyFromBooking.bookingContainers ?? []; 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); setContainerLines( lines.map((c) => { @@ -466,7 +477,7 @@ export default function GlCreateBookingForm() { })) : Array.from({ length: qty }, emptyUnit); return { - containerSize: String(c.containerType?.sizeFt ?? ""), + containerSize: scopeSizeForFt(c.containerType?.sizeFt), quantity: String(qty), hazardousQuantity: String(units.filter((u) => u.isHazardous).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 // portal form does. Subsequent renders reuse the lines.