From 4c549029fe43ad63a18b291287fdefe05b526189 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 21 Aug 2026 07:04:22 +0000 Subject: [PATCH] feat(clearance): preview charge documents before and after upload --- .../src/modules/bookings/ad-hoc-label.spec.ts | 33 ++ .../bookings/booking-pricing.service.spec.ts | 13 +- .../bookings/booking-pricing.service.ts | 15 +- .../bookings/booking-transition.service.ts | 9 +- .../src/modules/bookings/clearance.util.ts | 17 + .../contracts/booking-clearance.service.ts | 9 +- .../contracts/contract-clearance.service.ts | 5 +- .../contracts/gl-operations.service.ts | 10 +- .../train-scheduling/booking-batch.service.ts | 65 +++- .../booking-batch.yard-stock-debit.spec.ts | 115 ++++++ .../container-placement.util.ts | 35 +- .../dto/assign-bookings.dto.ts | 8 + .../services/train-scheduling.service.ts | 69 ++-- .../wagon-plan-flex.util.spec.ts | 47 +++ .../train-scheduling/wagon-plan-flex.util.ts | 68 +++- .../trains/dto/set-train-wagons-yard.dto.ts | 22 ++ .../trains/train-builder.controller.ts | 20 + .../modules/trains/train-builder.service.ts | 119 +++++- .../src/modules/wagons/wagons.service.ts | 5 + .../contracts/ClearanceChargesTab.tsx | 151 ++++++-- .../components/contracts/ClearanceOpsTabs.tsx | 2 +- .../contracts/ExportClearanceStepper.tsx | 291 ++------------ .../trainBuilder/ConsistWagonList.tsx | 356 ++++++++++++++---- .../trainBuilder/TrainBuilderDetailPage.tsx | 15 + .../backoffice/src/services/api.ts | 13 + .../src/services/trainBuilder.service.ts | 6 + .../components/ClearanceCard.tsx | 64 +++- .../components/CustomsPaymentsCard.tsx | 193 +--------- .../bookings/clearance/BookingActionModal.tsx | 15 +- .../bookings/clearance/ClearanceFlow.tsx | 6 +- .../bookings/clearance/useClearanceFlow.ts | 30 +- .../bookings/payments/useBookingPayables.ts | 29 +- 32 files changed, 1195 insertions(+), 660 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-batch.yard-stock-debit.spec.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/set-train-wagons-yard.dto.ts diff --git a/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts new file mode 100644 index 000000000..dd67b7acc --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/ad-hoc-label.spec.ts @@ -0,0 +1,33 @@ +import { adHocLabel } from './clearance.util'; + +/** + * The customer's typed document name travels to the API inside the multipart + * field code (`custom__`) — the only channel a part has — and comes + * back out here for GL's review grid. Mirror of `adHocSlug` in the portal's + * useClearanceFlow. + */ +const adHocSlug = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + +const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`); + +describe('adHocLabel', () => { + it('recovers the name the customer typed', () => { + expect(roundTrip('Special permit')).toBe('Special permit'); + expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate'); + expect(roundTrip('bank slip #2')).toBe('Bank slip 2'); + }); + + it('returns null when there is no name to show, so callers use the filename', () => { + expect(roundTrip('')).toBeNull(); + // Legacy uploads keyed `custom__` carry no name — without the + // digits guard this would surface "1755780000000" as the document label. + expect(adHocLabel('custom_1755780000000_0')).toBeNull(); + expect(adHocLabel('commercial_invoice')).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 99fc81c9a..ba1aaa875 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => { exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, {} as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); }); @@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const containerBooking = (overrides: Record = {}) => @@ -396,11 +398,14 @@ describe('BookingPricingService — customs clearance fee billed on the booking trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE', rateValue: 40, } as Rate; + // No serviceType relation on the booking (like the GL/portal shipment + // preview) — the flag must be resolved from serviceTypeId. const service = makeService({ liveRates: [containerFee20, ethiopianFee] }); + (service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = { + findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }), + }; const result = await service.computePriceForBooking( - containerBooking({ - serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true }, - } as never), + containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }), ); const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT'); @@ -574,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [], }), } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); // 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here. @@ -704,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, ); const booking = ( 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 5e8741df2..49732d2df 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 @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -84,6 +85,7 @@ export class BookingPricingService { private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, private readonly cargoTypesService: CargoTypesService, + private readonly serviceTypesService: ServiceTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -1061,8 +1063,17 @@ export class BookingPricingService { const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); // An Ethiopian-side-only customs service prices off its own rate; the - // contract froze its snapshots under the matching code prefix. - const customsType = booking.serviceType?.includesEthiopianCustomsOnly + // contract froze its snapshots under the matching code prefix. Resolved by + // id when the relation isn't loaded — the GL / portal shipment previews + // price a transient booking object, and a missing relation must not + // silently quote the standard fee the created booking is then billed + // differently for. + const serviceType = + booking.serviceType ?? + (booking.serviceTypeId + ? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null) + : null); + const customsType = serviceType?.includesEthiopianCustomsOnly ? 'ETHIOPIAN_CUSTOMS_CLEARANCE' : 'CUSTOMS_CLEARANCE'; const customsLabel = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 6d4d9073b..72261627e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { + adHocLabel, clearanceCodesForBooking, clearanceDocumentsOpen, } from './clearance.util'; @@ -742,7 +743,9 @@ export class BookingTransitionService { const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, - label: f.name, + // What the customer called it, falling back to the filename for rows + // uploaded before the name was carried through. + label: f.title || adHocLabel(f.code) || f.name, required: false, uploadedBy: "customer", settingCode: "custom", @@ -890,6 +893,10 @@ export class BookingTransitionService { resource: "bookings", code: file.fieldname, file, + // Ad-hoc uploads carry the name the customer typed (fieldname + // `custom_