add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 16:58:51 +00:00
parent 9b13fa2ac6
commit 5e10c97294
74 changed files with 3684 additions and 342 deletions

View File

@@ -30,18 +30,35 @@ export class BookingRequestService {
private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
private assertGeneralCustoms(contract: Contract): void {
if (
contract.contractKind !== 'GENERAL' ||
!contract.customsClearingEnabled
) {
/**
* Shipment requests exist because on a CUSTOMS contract the customer never
* books directly — GL Ethiopia does it for them. The request is how the
* customer states what to ship and, now, which currency to be invoiced in.
*
* GENERAL: each request opens its own per-booking clearance instance.
* ONE_TIME: clearance already ran at the contract level, so the request only
* records the customer's intent; GL creates the single booking from it.
*/
private assertCustomsContract(contract: Contract): void {
if (!contract.customsClearingEnabled) {
throw new BadRequestException(
'Shipment requests apply only to general customs-clearance contracts.',
'Shipment requests apply only to customs-clearance contracts.',
);
}
}
/**
* Statuses in which a ONE_TIME customs contract may take a shipment request:
* both signatures are in and the contract is at (or past) its clearance
* phase, but GL has not booked yet.
*/
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
'FULLY_EXECUTED',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
];
/** Customer submits a shipment request. */
async submit(
contractId: string,
@@ -50,13 +67,35 @@ export class BookingRequestService {
): Promise<BookingRequest> {
const contract = await this.contractsService.findById(contractId);
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
this.assertGeneralCustoms(contract);
this.assertCustomsContract(contract);
const isOneTime = contract.contractKind === 'ONE_TIME';
if (contract.status === 'CONTRACT_CLOSED') {
throw new ConflictException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
if (isOneTime) {
if (
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
contract.status,
)
) {
throw new ConflictException(
'The contract must be fully executed before requesting its shipment.',
);
}
// A one-time contract carries exactly one shipment, so it carries at most
// one open request — otherwise GL sees two conflicting currencies.
const open = (await this.repo.findForContract(contractId)).find(
(r) => r.status === 'PENDING',
);
if (open) {
throw new ConflictException(
`Shipment request ${open.reference} is already open on this contract.`,
);
}
} else if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',
);
@@ -90,10 +129,14 @@ export class BookingRequestService {
}
}
}
await this.contractBookingService.assertRequestWithinCapacity(contract, {
containers: dto.containers,
bulk: dto.bulk,
});
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
// shipment is bounded by the contract scope itself, checked when GL books.
if (!isOneTime) {
await this.contractBookingService.assertRequestWithinCapacity(contract, {
containers: dto.containers,
bulk: dto.bulk,
});
}
const requestedLines: Freight.RequestedShipmentLines = isContainer
? {
@@ -119,10 +162,17 @@ export class BookingRequestService {
// reviews the documents in the clearance queue and completes the booking
// (container numbers, VGM, shipment day) once clearance is ready. The
// instance is created first so a failure leaves no half-linked request.
const booking = await this.contractBookingService.initiateForShipmentRequest(
contract,
{ contractRouteId: dto.contractRouteId, userId },
);
// GENERAL: the request immediately opens a BARE booking instance that runs
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
// the contract, so there is nothing to open — the request stays PENDING
// until GL creates the contract's single booking from it.
const booking = isOneTime
? null
: await this.contractBookingService.initiateForShipmentRequest(contract, {
contractRouteId: dto.contractRouteId,
userId,
paymentCurrency: dto.paymentCurrency,
});
const reference = await this.generateReference();
const request = await this.repo.create({
@@ -131,9 +181,14 @@ export class BookingRequestService {
requestedByUserId: userId ?? null,
contractRouteId: dto.contractRouteId ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
status: 'ACCEPTED',
createdBookingId: booking.id,
status: booking ? 'ACCEPTED' : 'PENDING',
createdBookingId: booking?.id ?? null,
requestedLines,
// Intercity is invoiced in birr whatever the customer picked.
paymentCurrency:
contract.tradeDirection === 'DOMESTIC'
? 'ETB'
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
notes: dto.notes ?? null,
} as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);