implement contract cancellation feature and update contract statuses

- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
Marshal
2026-07-28 05:02:58 +00:00
parent 481878e553
commit 2429f6b629
83 changed files with 3083 additions and 3729 deletions

View File

@@ -113,13 +113,10 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
@@ -127,7 +124,7 @@ export class BookingClearanceService {
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
await this.assertPhasedCustoms(booking);
return booking;
}
@@ -352,11 +349,10 @@ export class BookingClearanceService {
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
);
}
@@ -720,7 +716,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
@@ -733,7 +729,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {

View File

@@ -60,6 +60,11 @@ export class BookingRequestService {
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',

View File

@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
expect(contractsRepository.update).not.toHaveBeenCalled();
});
describe('completion on booking delivery', () => {
function makeDeliveryService(contract: Partial<Contract>) {
const contractsRepository = {
findById: jest.fn().mockResolvedValue(contract),
update: jest.fn().mockResolvedValue(undefined),
};
const bookingsRepository = {
findById: jest
.fn()
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ createdToStaff: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, contractsRepository };
}
it('completes a ONE_TIME contract when its booking is delivered', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
bulk: null,
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
contractKind: 'GENERAL',
status: 'CONTRACT_ACTIVE',
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(

View File

@@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{
createdToStaff: jest.fn(),

View File

@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import {
ContractsRepository,
TERMINAL_BOOKING_STATUSES,
} from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
// same list.
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
@@ -94,7 +98,6 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -187,21 +190,14 @@ export class ContractBookingService {
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// EVERY contract booking clears per booking now — both contract kinds, both
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
// workflow on this booking. Non-customs (Path A) and intercity: the customer
// uploads his own document set on the booking and Operations reviews it
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation; intercity finalize goes straight to the ride-along pool).
// So the booking is always born in the clearance gate, never in the
// operations queue, and no contract-level clearance cycle exists to link.
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
@@ -218,24 +214,11 @@ export class ContractBookingService {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// No booking-window / export-space gate here any more: every contract
// booking enters the clearance gate first and is scheduled only once the
// documents are approved. Both checks run at that point instead —
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
// created with cargo) — against the day the customer actually picks.
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
@@ -265,10 +248,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -375,10 +355,7 @@ export class ContractBookingService {
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus = 'AWAITING_DOCUMENTS';
if (
withContainers &&
freightType === 'CONTAINER' &&
@@ -404,11 +381,7 @@ export class ContractBookingService {
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
@@ -417,13 +390,20 @@ export class ContractBookingService {
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
* or GENERAL, customs or not. One click, zero input: no schedule date, no
* cargo, no window check, no pricing. The instance starts in the clearance
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
*
* - 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).
*
* Only after the clearance is finalized is the booking completed (cargo +
* binding day + window check) via {@link completeUnderContract} — by the
* customer on Path A, by GL on Path B.
*/
async initiateUnderContract(
contractId: string,
@@ -434,13 +414,12 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
// Intercity has no shipment day to defer to, so it is booked directly with
// its cargo (the documents still live on that booking). Everything else —
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
if (contract.tradeDirection === 'DOMESTIC') {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
);
}
@@ -453,12 +432,27 @@ 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);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
// completion enforces that the next booking takes the whole remainder.
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
@@ -503,6 +497,16 @@ export class ContractBookingService {
} as never),
);
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
@@ -718,6 +722,15 @@ export class ContractBookingService {
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
// ONE_TIME split chain: the instance that follows a paid partial must take
// the WHOLE outstanding remainder — same rule a booking created with cargo
// passes at creation.
if (
contract.contractKind === 'ONE_TIME' &&
(await this.hasSplitBooking(contract.id))
) {
await this.assertExactRemainder(contract, dto);
}
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
@@ -817,10 +830,7 @@ export class ContractBookingService {
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
@@ -886,33 +896,17 @@ export class ContractBookingService {
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
// Customs runs per booking for BOTH contract kinds: seed the full milestone
// timeline on the booking. ensure* skips codes that already exist — an
// initiated instance carries its pre-booking milestones from initiation, and
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
@@ -982,10 +976,7 @@ export class ContractBookingService {
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
await this.finalizeContractBooking(id, contract).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
@@ -1001,32 +992,27 @@ export class ContractBookingService {
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): 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') {
throw new BadRequestException(
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
);
}
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
// 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) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — pre-booking boundary milestone must be complete.
const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Pre-booking clearance is not complete — booking cannot be created yet.',
'Contract must be fully executed before booking a shipment.',
);
}
return 'GL_ET';
@@ -1041,6 +1027,40 @@ export class ContractBookingService {
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
/**
* 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.
*/
async awaitingShipmentContracts(): Promise<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['FULLY_EXECUTED'],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: 'createdAt',
sortOrder: 'DESC',
} as never);
const out: Contract[] = [];
for (const contract of items) {
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
continue;
}
// A split chain frees the slot for the remainder, so those contracts stay
// on the list even while the paid partial booking still exists.
if (await this.hasSplitBooking(contract.id)) {
out.push(contract);
continue;
}
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
}
return out;
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
@@ -1418,6 +1438,53 @@ export class ContractBookingService {
];
}
/**
* A ONE_TIME contract carries exactly one shipment: once that booking is
* delivered (COMPLETED) the contract is fulfilled and moves to
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
* blocking any further booking. A split ONE_TIME is the exception: its
* remainder chain must be rebooked and delivered first, so the contract stays
* open while the split remainder is outstanding.
*
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
* Best-effort: a status hiccup must never fail the booking that completed.
*/
@OnEvent('booking.completed')
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(payload.bookingId);
if (!booking?.contractId) return;
const contract = await this.contractsRepository.findById(booking.contractId);
if (!contract || contract.contractKind === 'GENERAL') return;
// Already closed/expired/cancelled — nothing to do.
if (isEffectivelyExpired(contract)) return;
const outstanding = await this.splitOutstanding(contract);
if (outstanding) {
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
// cap-exhaustion path below.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
);
} catch (err) {
this.logger.error(
`Could not close contract for completed booking ${payload.bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract, and under a ONE_TIME

View File

@@ -34,7 +34,7 @@ import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -1060,46 +1060,6 @@ export class ContractClearanceService {
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
@@ -1693,80 +1653,4 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
}

View File

@@ -0,0 +1,79 @@
import { ConflictException } from '@nestjs/common';
import { ContractsService } from './contracts.service';
import type { CreateContractDto } from './dto/create-contract.dto';
/**
* The duplicate guard blocks a new request only when EVERY commercial
* dimension matches a live contract — service type, operation type, contract
* kind, cargo scope and route. Any one differing must let the request through.
*/
describe('ContractsService duplicate guard', () => {
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
const existing = {
id: 'c-1',
reference: 'CTR-2026-00001',
status: 'PENDING_APPROVAL',
contractValidUntil: null,
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
};
const dto = (overrides: Partial<CreateContractDto> = {}) =>
({
serviceTypeId: 'svc-1',
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
...overrides,
}) as CreateContractDto;
const guard = (input: CreateContractDto) => {
const service = new ContractsService(
{} as never,
{ findDuplicateCandidates: async () => [existing] } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return (
service as unknown as {
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
}
).assertNoDuplicateContract('company-1', input);
};
it('blocks an identical request', async () => {
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
});
it.each([
['operation type', { tradeDirection: 'EXPORT' }],
['contract kind', { contractKind: 'GENERAL' }],
['freight type', { freightType: 'BULK' }],
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
])('allows a request with a different %s', async (_label, overrides) => {
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
});
it('ignores quantity caps when comparing cargo scope', async () => {
await expect(
guard(
dto({
cargoScope: [
{ containerSize: '20ft', quantityCap: 10 },
{ containerSize: '40ft', quantityCap: 5 },
],
}),
),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -143,6 +143,33 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/** Backoffice froze the contract — every action on it is blocked until lifted. */
suspended(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
void this.notifyContact(c, msg, 'SUSPENDED');
this.inApp(c, 'Contract suspended', msg);
}
/** Backoffice lifted the suspension — the contract resumes where it left off. */
suspensionLifted(c: Contract, note?: string | null): void {
const msg =
`The suspension on your contract ${c.reference} has been lifted. ` +
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
this.inApp(c, 'Contract suspension lifted', msg);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(
c,
'Contract cancelled by customer',
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their

View File

@@ -0,0 +1,132 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* Suspension is only worth having if it is reversible and if it actually
* freezes things, and the customer's own cancel is only safe while no shipment
* is running. Those three rules are the whole feature — everything else is
* plumbing.
*/
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
status: 'CONTRACT_ACTIVE',
freightType: 'CONTAINER',
...over,
}) as Contract;
let current: Contract;
let repo: {
update: jest.Mock;
createReviewNote: jest.Mock;
countActiveBookings: jest.Mock;
};
let notifier: {
suspended: jest.Mock;
suspensionLifted: jest.Mock;
cancelledByCustomer: jest.Mock;
};
let service: ContractTransitionService;
/** A staff user holding the suspend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
};
beforeEach(() => {
current = contract();
repo = {
// Mirror the real repository: the update patches the row the next
// findById returns, so resume() reads what suspend() wrote.
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
countActiveBookings: jest.fn().mockResolvedValue(0),
};
notifier = {
suspended: jest.fn(),
suspensionLifted: jest.fn(),
cancelledByCustomer: jest.fn(),
};
// These three transitions touch only the repository, the read-back service
// and the notifier — the other 14 constructor deps stay unused, so the
// instance is built bare and only what is exercised is injected.
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('freezes at the current step and remembers where to come back to', async () => {
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'SUSPENDED',
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
});
expect(notifier.suspended).toHaveBeenCalled();
});
it('restores the pre-suspension status when the suspension is lifted', async () => {
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
await service.resume('c-1', undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
statusBeforeSuspension: null,
});
});
it('refuses to suspend a contract the customer has not signed yet', async () => {
current = contract({ status: 'PENDING_APPROVAL' });
await expect(
service.suspend('c-1', 'too early', 'staff-1', staff as never),
).rejects.toThrow(/PENDING_APPROVAL/);
expect(repo.update).not.toHaveBeenCalled();
});
it('lets the customer cancel a contract with no live shipment', async () => {
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Changed supplier',
'CANCELLATION',
'user-1',
'CUSTOMER',
);
});
it('blocks the customer cancel while a shipment is still running', async () => {
repo.countActiveBookings.mockResolvedValue(2);
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/2 active shipments/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
current = contract({ status: 'SUSPENDED' });
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/suspended/);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,7 @@ import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import {
Contract,
ContractDocumentArticle,
@@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
.join(' and ');
}
/**
* Where the backoffice may freeze a contract: every step from the customer's
* signature onward, up to (but not including) the terminal states. Suspending
* an unsigned contract is meaningless — staff reject or request changes there.
*/
export const SUSPENDABLE_CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -154,7 +168,6 @@ export class ContractTransitionService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@@ -1262,43 +1275,15 @@ export class ContractTransitionService {
lockedAt: now,
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// No prepay gate: the customs clearance service fee (Path B) is billed on
// the booking invoice together with the freight, so the document step
// opens immediately.
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
// 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.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
@@ -1308,6 +1293,123 @@ export class ContractTransitionService {
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
/**
* Backoffice freeze, available at every step from the customer signature
* onward. The pre-suspension status is stashed so {@link resume} can put the
* contract back exactly where it was — a suspension you cannot lift is just a
* cancellation under another name.
*
* While SUSPENDED nothing moves: no new bookings or shipment requests
* (ContractBookingService / BookingRequestService), and no writes to the
* contract's existing bookings (BookingsRepository.update).
*/
async suspend(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'SUSPENSION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'SUSPENDED',
statusBeforeSuspension: contract.status,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspended(updated, reason);
return updated;
}
/** Lift a suspension — the contract returns to the status it was frozen at. */
async resume(
contractId: string,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, ['SUSPENDED']);
// Legacy safety net: a row suspended before the column existed has nothing
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
// contract kinds, so it is the only sane default.
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
if (note?.trim()) {
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'SUSPENSION_LIFTED',
actorId,
'STAFF',
);
}
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspensionLifted(updated, note ?? null);
return updated;
}
/**
* Customer cancels their own contract so they can request a fresh one for the
* same lane — the duplicate-contract guard treats CANCELLED as released.
* Blocked while any booking on the contract is still live: cancelling a
* contract with cargo in motion would strand it.
*/
async cancelByCustomer(
contractId: string,
reason: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended by EDR — contact us to lift the suspension first.',
);
}
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
const body = reason?.trim() || 'Cancelled by the customer.';
await this.contractsRepository.createReviewNote(
contractId,
body,
'CANCELLATION',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByCustomer(updated, body);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
CancelContractDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -453,6 +456,65 @@ export class ContractsController {
);
}
@Post(':id/suspend')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
})
suspend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SuspendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.suspend(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/resume')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
resume(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ResumeContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.resume(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
// through, everyone else must own the contract's company.
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.cancelByCustomer(
id,
dto.reason,
resolveAuthUserId(user),
);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@@ -924,31 +986,18 @@ export class ContractsController {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
@Get('awaiting-shipment')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
})
awaitingShipmentContracts() {
return this.contractBookingService.awaitingShipmentContracts();
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
@@ -1017,7 +1066,7 @@ export class ContractsController {
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
'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.',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@@ -1335,7 +1384,7 @@ export class ContractsController {
) {
const file = (files ?? [])[0];
const booking = await this.bookingsService.findById(bookingId);
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
@@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
/**
* Booking statuses that release whatever the booking was holding — contract
* capacity, the one-time active slot, the cancel gate. Everything else counts
* as a live booking.
*/
export const TERMINAL_BOOKING_STATUSES = [
'EXPIRED',
'CANCELLED',
'COMPLETED',
'REJECTED',
];
export interface ContractListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* Non-terminal contracts for the same company + service type, with routes and
* cargo scope loaded — candidates for the duplicate-contract check on
* create() (which also compares operation type, kind and scope). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
@@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
@@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
// ── Review notes ──────────────────────────────────────────────────────────────
/**
* Bookings on the contract that have not reached a terminal state. Gates the
* customer's own contract cancellation (a contract carrying live cargo may
* not be cancelled) and is surfaced on the detail response so the portal can
* disable the button instead of failing the call.
*/
async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.getCount();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
.join(', ');
}
/**
* Order-independent identity of a cargo scope — two contracts cover the same
* cargo only when they list the same container sizes / commodities. Quantity
* caps are deliberately ignored: they size a GENERAL contract, they don't make
* it a different scope.
*/
function cargoScopeKey(
scope?: Array<
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
> | null,
): string {
if (!scope?.length) return '';
return scope
.map((row) =>
[
row.containerSize?.trim().toLowerCase() ?? '',
row.cargoTypeId ?? '',
row.cargoFreeText?.trim().toLowerCase() ?? '',
].join('|'),
)
.sort()
.join(',');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -190,25 +214,34 @@ export class ContractsService {
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
* A live contract only blocks a new request when EVERY commercial dimension
* of the wizard matches it: service type, operation type (trade direction),
* contract kind, cargo scope and route. Change any one of them — a different
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
* customer may request another contract.
*
* A route "overlaps" if any origin/destination pair matches; cargo scope
* matches only when the two scope sets are identical (same freight type and
* the same container sizes / commodities).
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
dto: CreateContractDto,
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
dto.serviceTypeId,
);
const incomingScope = cargoScopeKey(dto.cargoScope);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
c.tradeDirection === dto.tradeDirection &&
c.contractKind === dto.contractKind &&
c.freightType === dto.freightType &&
cargoScopeKey(c.cargoScope) === incomingScope &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
dto.routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
@@ -220,7 +253,7 @@ export class ContractsService {
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
);
}
}
@@ -257,7 +290,7 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
await this.assertNoDuplicateContract(companyId, dto);
}
// Stamp the operational profile for portal scoping. A forwarder contract
@@ -813,6 +846,24 @@ export class ContractsService {
}
}
// Why the contract is frozen — shown to staff and customer alike.
if (contract.status === 'SUSPENDED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'SUSPENSION',
);
contract.latestSuspensionNote = note?.body ?? null;
} catch {
contract.latestSuspensionNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =
await this.contractsRepository.countActiveBookings(contract.id);
return contract;
}

View File

@@ -50,3 +50,17 @@ export class CancelContractDto {
@IsString()
reason?: string;
}
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()
@MinLength(1)
reason!: string;
}
export class ResumeContractDto {
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
* correct it. One row per round — the advice/dispute loop can repeat.
*/
'DUTY_DISPUTE',
/** Backoffice froze the contract; body is the reason shown to the customer. */
'SUSPENSION',
/** Backoffice lifted a suspension; body is the optional lift note. */
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
// Reversible backoffice freeze — see statusBeforeSuspension.
'SUSPENDED',
'CONTRACT_CLOSED',
'EXPIRED',
'REJECTED',
@@ -217,6 +219,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* Status the contract held when the backoffice suspended it, restored when
* the suspension is lifted. Null unless the contract is (or once was)
* SUSPENDED. A suspension without this would just be a cancellation.
*/
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -343,4 +353,18 @@ export class Contract extends BaseEntity {
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
/**
* Body of the most recent SUSPENSION review note, attached by
* ContractsService.findById while the contract is SUSPENDED so both sides see
* why it was frozen. Lives in contract_review_notes, not a column here.
*/
latestSuspensionNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while
* it is > 0 (the API enforces the same). Not a column.
*/
activeBookingCount?: number;
}