Files
edr-platform/apps/edr-freight-api/src/modules/bookings/clearance.util.ts

166 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Booking } from './entities/booking.entity';
/**
* Resolves which seeded clearance FileUploadSetting applies to a booking, from
* its trade direction, freight type and whether its service includes customs.
* Mirrors the codes seeded in file-upload-settings.seeder.ts.
*/
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves every intercity booking —
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/**
* The document set a shipping line uploads on a booking it initiated.
*
* Shipping lines book without a contract, so none of the trade-direction /
* freight / customs matrix below applies to them — this one admin-configured
* set is what Operations reviews before the booking may be completed.
*/
export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE =
'shipping_line_booking_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no customs operation
}
function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container';
}
/** The customer-input clearance setting code, or null when no gate applies. */
export function clearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
// Intercity: no customs, but the admin-configured intercity document set is
// still collected and ops-reviewed before the shipment may board a train.
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
// 4 import + 4 export cases (bulk/container × with/without customs) — each
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
// set, independent of any contract-level clearance codes.
if (!includesCustoms) {
return `clearance_${op}_${freight}_without_customs`;
}
return `clearance_${op}_${freight}_with_customs`;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */
export function clearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
return `clearance_output_${op}_${freight}`;
}
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */
export function clearanceCodesForBooking(booking: Booking): {
inputCode: string | null;
outputCode: string | null;
includesCustoms: boolean;
} {
// Shipping-line bookings resolve to their own single set and never reach the
// matrix below: they have no contract, and their trade direction / freight
// type are placeholders until the booking is completed, so the customer codes
// would resolve to a set that was never meant for them. Keyed off the owner
// column, which is NULL on every customer booking — so no customer booking
// can take this branch.
if (booking.shippingLineCompanyId) {
return {
inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE,
outputCode: null,
includesCustoms: false,
};
}
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation
// isn't loaded / has includesCustoms=false — without this the per-booking
// clearance grid would resolve empty.
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,
booking.freightType,
includesCustoms,
),
outputCode: clearanceOutputSettingCode(
booking.tradeDirection,
booking.freightType,
includesCustoms,
),
includesCustoms,
};
}
/**
* Statuses after which clearance documents are closed: the shipment is paid
* and moving. Everything before that — review, clearance ready, operation
* request, batch selection, PNR, payment verification — still accepts new
* customer documents and still lets GL review them.
*/
const CLEARANCE_DOCS_CLOSED_STATUSES = new Set<string>([
'PAID',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'REJECTED',
'CANCELLED',
'EXPIRED',
]);
/**
* True while the customer may still attach clearance documents and GL may
* still approve or query them.
*
* Clearance finalization is NOT the cut-off: a customs shipment keeps
* collecting paperwork (amended invoices, revised packing lists, port
* documents) right up to the final invoice being settled. Both the customer's
* upload endpoint and GL's review endpoint gate on this one predicate, so the
* two sides can never drift apart.
*/
export function clearanceDocumentsOpen(booking: Booking): boolean {
if (CLEARANCE_DOCS_CLOSED_STATUSES.has(booking.status)) return false;
// Payment settled ahead of the status transition (webhook ordering).
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;
}