mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +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
|
* - Path A (self-clearance): the customer initiates, uploads his clearance
|
||||||
* proof, Operations reviews and finalizes.
|
* proof, Operations reviews and finalizes.
|
||||||
* - Path B (customs): GL initiates on the customer's behalf, the customer
|
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
|
||||||
* uploads the GL-input documents on the instance, GL approves them and runs
|
* GL-input documents on the instance; GL approves them and runs the phased
|
||||||
* the phased ET/DJ workflow (pre-booking milestones are seeded here).
|
* 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 +
|
* Only after the clearance is finalized is the booking completed (cargo +
|
||||||
* binding day + window check) via {@link completeUnderContract} — by the
|
* binding day + window check) via {@link completeUnderContract} — by the
|
||||||
@@ -432,9 +434,10 @@ export class ContractBookingService {
|
|||||||
const isGlActor =
|
const isGlActor =
|
||||||
actorPermissions != null &&
|
actorPermissions != null &&
|
||||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||||
// Customs (Path B): GL initiates on the customer's behalf — assertGate
|
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||||
// rejects anyone else. Self-clearance (Path A): the customer initiates.
|
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
// 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()) {
|
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
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
|
* 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.
|
* 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
|
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||||
// of letting the executed-status check below give a misleading reason.
|
// of letting the executed-status check below give a misleading reason.
|
||||||
if (contract.status === 'SUSPENDED') {
|
if (contract.status === 'SUSPENDED') {
|
||||||
@@ -1000,10 +1007,13 @@ export class ContractBookingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
// Path B — Global Logistics initiates and completes the booking ON BEHALF
|
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
|
||||||
// OF the customer. The customer never books a customs contract himself;
|
// contract (one click, no cargo) and uploads the GL-input documents on it;
|
||||||
// he only uploads documents on the instance GL opened for him.
|
// GL still runs the phased ET/DJ clearance and completes the booking with
|
||||||
if (!isGlActor) {
|
// 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(
|
throw new ForbiddenException(
|
||||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
'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.',
|
'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.
|
// 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
|
* GL fallback worklist: executed ONE_TIME customs contracts with no live
|
||||||
* instance yet. Customs contracts are initiated by GL on the customer's
|
* shipment instance yet. The customer normally opens it himself from the
|
||||||
* behalf, so without this list a signed contract would sit with nothing on any
|
* portal; this list lets GL do it on his behalf, and shows the contracts that
|
||||||
* queue (clearance lives on the booking, and the booking does not exist yet).
|
* are on no other queue (clearance lives on the booking, which does not exist
|
||||||
* GENERAL customs is excluded — its instances are opened by shipment requests.
|
* yet). GENERAL customs is excluded — opened by shipment requests.
|
||||||
*/
|
*/
|
||||||
async awaitingShipmentContracts(): Promise<Contract[]> {
|
async awaitingShipmentContracts(): Promise<Contract[]> {
|
||||||
const { items } = await this.contractsRepository.findAllPaginated({
|
const { items } = await this.contractsRepository.findAllPaginated({
|
||||||
|
|||||||
@@ -1278,9 +1278,10 @@ export class ContractTransitionService {
|
|||||||
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
|
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
|
||||||
// intercity. A signed contract carries no clearance cycle and collects no
|
// intercity. A signed contract carries no clearance cycle and collects no
|
||||||
// documents: the shipment instance created after signature does. Customs
|
// documents: the shipment instance created after signature does. Customs
|
||||||
// (Path B): GL initiates the booking, the customer uploads on it, GL
|
// (Path B): the customer initiates the booking (GENERAL: via a shipment
|
||||||
// reviews and completes it. Self-clearance (Path A) and intercity: the
|
// request) and uploads on it, GL reviews and completes it. Self-clearance
|
||||||
// customer initiates/books and Operations reviews the booking documents.
|
// (Path A) and intercity: the customer initiates/books and Operations
|
||||||
|
// reviews the booking documents.
|
||||||
updates.status =
|
updates.status =
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ export class ContractsController {
|
|||||||
@Post(':id/bookings/initiate')
|
@Post(':id/bookings/initiate')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
initiateBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -375,10 +375,9 @@ export default function ContractDetailPage() {
|
|||||||
const cancellable = cancelStatusOk && activeShipments === 0;
|
const cancellable = cancelStatusOk && activeShipments === 0;
|
||||||
|
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
// Only the NON-customs (Path A) customer books himself — once the contract is
|
// A ONE_TIME contract's shipment instance is opened by the customer on both
|
||||||
// executed after self-clearance. Customs (Path B) bookings are created by
|
// paths; on customs (Path B) GL then clears it and completes it with cargo and
|
||||||
// Global Logistics on the customer's behalf, so the customer gets no booking
|
// price. GENERAL customs stays request-driven, so no booking button there.
|
||||||
// button on a customs contract.
|
|
||||||
const bookingAction = getContractBookingAction(contract, contractBookings);
|
const bookingAction = getContractBookingAction(contract, contractBookings);
|
||||||
// Whether the customer may open a new self-service booking. Derived from the
|
// 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:
|
// 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}>
|
<Text fz={13} c="dimmed" ta="center" maw={380}>
|
||||||
{canBookShipment
|
{canBookShipment
|
||||||
? "No bookings yet. Use “New booking” to ship against this contract."
|
? "No bookings yet. Use “New booking” to ship against this contract."
|
||||||
: customsPath
|
: canInitiateBooking
|
||||||
? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it."
|
? `No shipments yet. Start one with “Initiate booking” — you upload the ${bookingDocNoun(contract)} on that shipment.`
|
||||||
: canInitiateBooking
|
: customsPath
|
||||||
? `No shipments yet. Start one with “Initiate booking” — you upload the ${bookingDocNoun(contract)} on that shipment.`
|
? "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."}
|
: "Bookings appear here once the contract is fully executed."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export interface ContractBookingAction {
|
|||||||
* The single source of truth for whether a contract row should show a
|
* The single source of truth for whether a contract row should show a
|
||||||
* Book / Re-book shipment button, and where it should navigate.
|
* Book / Re-book shipment button, and where it should navigate.
|
||||||
*
|
*
|
||||||
* - ONE_TIME: one shipment instance at a time. Import/export self-clearance
|
* - ONE_TIME: one shipment instance at a time. Import/export initiates a bare
|
||||||
* initiates a bare instance (clearance first); intercity books directly.
|
* instance (clearance first) whether it self-clears or goes through customs;
|
||||||
* Customs is initiated by GL, so the customer gets no button.
|
* intercity books directly.
|
||||||
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
|
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
|
||||||
* already excluded by the PATH_A_BOOKABLE gate).
|
* 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
|
// Customs: only a ONE_TIME import/export contract is opened by the customer —
|
||||||
// it — the customer only uploads documents on that booking. No action here.
|
// he initiates the bare instance and uploads the customs documents on it, GL
|
||||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
// 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: "" };
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
const to = `/contracts/${contract.id}/bookings/new`;
|
const to = `/contracts/${contract.id}/bookings/new`;
|
||||||
|
|||||||
Reference in New Issue
Block a user