mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user