From 653d5782ee1a82fb484fad5c64e8e62114915d7a Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 28 Jul 2026 07:21:04 +0000 Subject: [PATCH] update body-parser implementation to use Nest's API and improve dependency handling --- .../contract-booking.initiate-gate.spec.ts | 74 +++++++++++++++++++ .../contracts/contract-booking.service.ts | 44 ++++++----- .../contracts/contract-transition.service.ts | 7 +- .../modules/contracts/contracts.controller.ts | 2 +- .../pages/contracts/ContractDetailPage.tsx | 15 ++-- .../contracts/contract-booking-action.ts | 21 ++++-- 6 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts new file mode 100644 index 000000000..6b5f54c91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -0,0 +1,74 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { ContractBookingService } from './contract-booking.service'; +import { Contract } from './entities/contract.entity'; + +/** + * Who may open a shipment instance on a customs (Path B) contract. The customer + * initiates his own ONE_TIME customs booking and uploads the GL-input documents + * on it; GL still clears it and completes it with cargo and price. GENERAL + * customs instances come from a shipment request, and completing/creating a + * customs booking outright stays GL-only. + */ +describe('ContractBookingService — customs booking gate', () => { + function makeService() { + return new ContractBookingService( + {} as never, // contractsRepository + {} as never, // bookingsRepository + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // invoiceService + {} as never, // bookingNotifier + {} as never, // dataSource + {} as never, // trainSchedulingService + {} as never, // bookingBatchService + {} as never, // bookingTransitionService + ); + } + + type WithPrivate = { + assertGate: ( + c: Contract, + isGlActor: boolean, + isInitiate?: boolean, + ) => Promise; + }; + + const customsContract = (contractKind: 'ONE_TIME' | 'GENERAL'): Contract => + ({ + id: 'c-1', + contractKind, + status: 'FULLY_EXECUTED', + customsClearingEnabled: true, + }) as Contract; + + const gate = (c: Contract, isGl: boolean, isInitiate?: boolean) => + (makeService() as never as WithPrivate).assertGate(c, isGl, isInitiate); + + it('lets the customer initiate a ONE_TIME customs shipment', async () => { + await expect(gate(customsContract('ONE_TIME'), false, true)).resolves.toBe( + 'CUSTOMER', + ); + }); + + it('still lets GL initiate on the customer behalf', async () => { + await expect(gate(customsContract('ONE_TIME'), true, true)).resolves.toBe( + 'GL_ET', + ); + }); + + it('rejects a customer creating a customs booking outright (cargo + day)', async () => { + await expect(gate(customsContract('ONE_TIME'), false)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('rejects a customer initiating a GENERAL customs shipment (request only)', async () => { + await expect( + gate(customsContract('GENERAL'), false, true), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); 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 3a97331af..c567f71ce 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 @@ -397,9 +397,11 @@ export class ContractBookingService { * * - Path A (self-clearance): the customer initiates, uploads his clearance * proof, Operations reviews and finalizes. - * - Path B (customs): GL initiates on the customer's behalf, the customer - * uploads the GL-input documents on the instance, GL approves them and runs - * the phased ET/DJ workflow (pre-booking milestones are seeded here). + * - Path B (customs, ONE_TIME): the customer initiates too, then uploads the + * GL-input documents on the instance; GL approves them and runs the phased + * ET/DJ workflow (pre-booking milestones are seeded here). GL may still + * initiate on his behalf. GENERAL customs instances come from a shipment + * request ({@link initiateForShipmentRequest}), not from here. * * Only after the clearance is finalized is the booking completed (cargo + * binding day + window check) via {@link completeUnderContract} — by the @@ -432,9 +434,10 @@ export class ContractBookingService { const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); - // Customs (Path B): GL initiates on the customer's behalf — assertGate - // rejects anyone else. Self-clearance (Path A): the customer initiates. - const createdByRole = await this.assertGate(contract, isGlActor); + // The customer initiates his own shipment instance on ONE_TIME contracts + // (customs or self-clearance); GL may also initiate on a customs contract. + // GENERAL customs instances come from a shipment request, not from here. + const createdByRole = await this.assertGate(contract, isGlActor, true); if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); @@ -991,7 +994,11 @@ export class ContractBookingService { * Returns the role to stamp on the booking, or throws if the caller is not * allowed to create one for this contract's execution path. */ - private async assertGate(contract: Contract, isGlActor: boolean): Promise { + private async assertGate( + contract: Contract, + isGlActor: boolean, + isInitiate = false, + ): Promise { // Suspended contracts are frozen for everyone, GL included — say so instead // of letting the executed-status check below give a misleading reason. if (contract.status === 'SUSPENDED') { @@ -1000,10 +1007,13 @@ export class ContractBookingService { ); } if (contract.customsClearingEnabled) { - // Path B — Global Logistics initiates and completes the booking ON BEHALF - // OF the customer. The customer never books a customs contract himself; - // he only uploads documents on the instance GL opened for him. - if (!isGlActor) { + // Path B — the customer OPENS the shipment instance on a ONE_TIME customs + // contract (one click, no cargo) and uploads the GL-input documents on it; + // GL still runs the phased ET/DJ clearance and completes the booking with + // cargo, day and price. A GENERAL customs instance is opened by a shipment + // request instead, and completing any customs booking stays GL-only. + const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME'; + if (!isGlActor && !customerMayInitiate) { throw new ForbiddenException( 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', ); @@ -1015,7 +1025,7 @@ export class ContractBookingService { 'Contract must be fully executed before booking a shipment.', ); } - return 'GL_ET'; + return isGlActor ? 'GL_ET' : 'CUSTOMER'; } // Path A — customer (or staff) once the contract is executed. @@ -1028,11 +1038,11 @@ export class ContractBookingService { } /** - * GL worklist: executed ONE_TIME customs contracts with no live shipment - * instance yet. Customs contracts are initiated by GL on the customer's - * behalf, so without this list a signed contract would sit with nothing on any - * queue (clearance lives on the booking, and the booking does not exist yet). - * GENERAL customs is excluded — its instances are opened by shipment requests. + * GL fallback worklist: executed ONE_TIME customs contracts with no live + * shipment instance yet. The customer normally opens it himself from the + * portal; this list lets GL do it on his behalf, and shows the contracts that + * are on no other queue (clearance lives on the booking, which does not exist + * yet). GENERAL customs is excluded — opened by shipment requests. */ async awaitingShipmentContracts(): Promise { const { items } = await this.contractsRepository.findAllPaginated({ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 5f85cf663..8f616d7a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -1278,9 +1278,10 @@ export class ContractTransitionService { // Clearance ALWAYS runs per booking — both contract kinds, both paths, and // intercity. A signed contract carries no clearance cycle and collects no // documents: the shipment instance created after signature does. Customs - // (Path B): GL initiates the booking, the customer uploads on it, GL - // reviews and completes it. Self-clearance (Path A) and intercity: the - // customer initiates/books and Operations reviews the booking documents. + // (Path B): the customer initiates the booking (GENERAL: via a shipment + // request) and uploads on it, GL reviews and completes it. Self-clearance + // (Path A) and intercity: the customer initiates/books and Operations + // reviews the booking documents. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 4d70d4203..ab330ae9c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1068,7 +1068,7 @@ export class ContractsController { @Post(':id/bookings/initiate') @ApiOperation({ summary: - 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). Customs contracts are initiated by GL Ethiopia.', + 'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 7527d37ca..659b5161d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -375,10 +375,9 @@ export default function ContractDetailPage() { const cancellable = cancelStatusOk && activeShipments === 0; const customsPath = contract.customsClearingEnabled; - // Only the NON-customs (Path A) customer books himself — once the contract is - // executed after self-clearance. Customs (Path B) bookings are created by - // Global Logistics on the customer's behalf, so the customer gets no booking - // button on a customs contract. + // A ONE_TIME contract's shipment instance is opened by the customer on both + // paths; on customs (Path B) GL then clears it and completes it with cargo and + // price. GENERAL customs stays request-driven, so no booking button there. const bookingAction = getContractBookingAction(contract, contractBookings); // Whether the customer may open a new self-service booking. Derived from the // shared booking-action helper so it honours the ONE_TIME single-slot rule: @@ -1229,10 +1228,10 @@ export default function ContractDetailPage() { {canBookShipment ? "No bookings yet. Use “New booking” to ship against this contract." - : customsPath - ? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it." - : canInitiateBooking - ? `No shipments yet. Start one with “Initiate booking” — you upload the ${bookingDocNoun(contract)} on that shipment.` + : canInitiateBooking + ? `No shipments yet. Start one with “Initiate booking” — you upload the ${bookingDocNoun(contract)} on that shipment.` + : customsPath + ? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it." : "Bookings appear here once the contract is fully executed."} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts b/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts index be555d873..225374f6f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-booking-action.ts @@ -40,9 +40,9 @@ export interface ContractBookingAction { * The single source of truth for whether a contract row should show a * Book / Re-book shipment button, and where it should navigate. * - * - ONE_TIME: one shipment instance at a time. Import/export self-clearance - * initiates a bare instance (clearance first); intercity books directly. - * Customs is initiated by GL, so the customer gets no button. + * - ONE_TIME: one shipment instance at a time. Import/export initiates a bare + * instance (clearance first) whether it self-clears or goes through customs; + * intercity books directly. * - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are * already excluded by the PATH_A_BOOKABLE gate). */ @@ -62,9 +62,18 @@ export function getContractBookingAction( }; } - // Other customs (ONE_TIME): GL initiates the shipment instance and completes - // it — the customer only uploads documents on that booking. No action here. - if (contract.customsClearingEnabled) return { kind: "none", to: "" }; + // Customs: only a ONE_TIME import/export contract is opened by the customer — + // he initiates the bare instance and uploads the customs documents on it, GL + // clears it and completes it with cargo and price. GENERAL customs went + // through the shipment-request branch above; intercity customs is booked by GL + // directly with its cargo. + if ( + contract.customsClearingEnabled && + (contract.contractKind !== "ONE_TIME" || + contract.tradeDirection === "DOMESTIC") + ) { + return { kind: "none", to: "" }; + } if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" }; const to = `/contracts/${contract.id}/bookings/new`;