mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user