Merge pull request #989 from Tria-plc/freight_feature/usermanagement

update body-parser implementation to use Nest's API and improve depe…
This commit is contained in:
marshal
2026-07-28 10:25:05 +03:00
committed by GitHub
6 changed files with 128 additions and 35 deletions

View File

@@ -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<string>;
};
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);
});
});

View File

@@ -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<string> {
private async assertGate(
contract: Contract,
isGlActor: boolean,
isInitiate = false,
): Promise<string> {
// 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<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({

View File

@@ -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';

View File

@@ -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,

View File

@@ -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() {
<Text fz={13} c="dimmed" ta="center" maw={380}>
{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."}
</Text>
</Stack>

View File

@@ -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`;