feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -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_<slug>_<n>`) — 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_<timestamp>_<n>` 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();
});
});

View File

@@ -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<string, unknown> = {}) =>
@@ -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 = (

View File

@@ -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<GeneratePriceResponseDto> {
@@ -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 =

View File

@@ -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_<label>_<n>`); it is what GL sees in the review grid instead
// of a raw filename like "scan_003.pdf".
title: adHocLabel(file.fieldname),
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith("custom_")

View File

@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
if (booking.paymentStatus === 'PAID') return false;
return true;
}
/**
* The label the customer typed for an ad-hoc clearance document, recovered from
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
* back to the filename.
*/
export function adHocLabel(fileKey: string): string | null {
const m = /^custom_(.+)_\d+$/.exec(fileKey);
if (!m) return null;
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
// of digits. Those carry no name — reject them so the caller falls back to
// the filename instead of showing "1755780000000".
if (/^\d+$/.test(m[1])) return null;
const label = m[1].replace(/-/g, ' ').trim();
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
}