Implement comprehensive Global Logistics workflow for Unimodal Export and Import processes, including contract verification, documentation submission, compliance management, customs clearance, payment settlement, and last-mile transport handling.

This commit is contained in:
Marshal
2026-06-27 18:54:03 +00:00
parent 0a23ade118
commit e977893888
21 changed files with 1985 additions and 268 deletions

View File

@@ -235,13 +235,55 @@ export class ContractClearanceService {
}
}
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
/**
* Path A (no customs) — the customer clears the cargo himself and uploads his
* own clearance proof, reviewed by Operations rather than GL. True when a
* clearance doc set resolves for a non-customs contract.
*/
private isSelfClear(contract: Contract): boolean {
if (contract.customsClearingEnabled) return false;
return contractClearanceCodes(contract).inputCode != null;
}
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
async reviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
}
/**
* Operations (Path A) reviews a customer self-clearance document. Identical
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
* contracts, which are GL-reviewed.
*/
async opsReviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations review applies only to self-clearance (non-customs) contracts.',
);
}
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
}
private async applyReview(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
reviewerRole: 'GL_ET' | 'OPERATIONS',
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
@@ -280,7 +322,7 @@ export class ContractClearanceService {
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
'GL_ET',
reviewerRole,
);
// Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, {
@@ -325,11 +367,17 @@ export class ContractClearanceService {
}
/**
* GL ET finalizes pre-booking clearance: requires every customer document
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
* GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
*/
async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (this.isSelfClear(contract)) {
throw new ConflictException(
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
@@ -376,8 +424,46 @@ export class ContractClearanceService {
}
/**
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
* CLEARANCE_UNDER_REVIEW (customs contracts only).
* Operations finalizes Path A self-clearance: requires every customer document
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
*/
async opsFinalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations finalize applies only to self-clearance (non-customs) contracts.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
clearanceStatus: 'SELF_CLEARED',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
clearanceReadyAt: new Date(),
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
*/
async queue(
filter: FilterContractDto,
@@ -388,6 +474,22 @@ export class ContractClearanceService {
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});

View File

@@ -20,16 +20,28 @@ function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container';
}
/** The customer-input clearance setting code, or null when no gate applies. */
/**
* The customer-input clearance setting code, or null when no gate applies.
*
* - Path B (customs bundled): the customer uploads the documents GL needs to do
* the clearance work → `contract_clearance_{op}_{freight}`.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${op}_${freight}`;
}

View File

@@ -22,6 +22,7 @@ import { ContractPricingService } from './contract-pricing.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 } from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -441,9 +442,14 @@ export class ContractTransitionService {
}
/**
* Staff/Director/CEO counter-sign → branch on customs:
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
* who reviews differs:
* - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
* uploads output, then GL creates the booking.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own clearance proof; Operations reviews it; then the CUSTOMER books.
* DOMESTIC/intercity has no border, so it goes straight to executed.
*/
async counterSign(
contractId: string,
@@ -461,9 +467,19 @@ export class ContractTransitionService {
lockedAt: now,
};
if (contract.customsClearingEnabled) {
// Path B — open a clearance cycle, seed the pre-booking milestones, and
// route the customer to the document upload.
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
if (clearanceCode) {
// 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);
@@ -471,7 +487,7 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// Path A — transport only; ready for the customer to book.
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';

View File

@@ -405,6 +405,45 @@ export class ContractsController {
return this.clearanceService.finalize(id);
}
// ── 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({
summary: 'Operations reviews a customer self-clearance document (Approve | Query)',
})
opsReviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewClearanceDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.opsReviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/clearance/ops-finalize')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations finalizes self-clearance → customer may create the booking',
})
opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.opsFinalize(id);
}
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')

View File

@@ -25,6 +25,7 @@ export interface ContractListFilterOptions {
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
}
@@ -209,6 +210,11 @@ export class ContractsRepository extends BaseRepository<Contract> {
contractKind: options.contractKind,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.serviceTypeId) {
qb.andWhere('contract.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,

View File

@@ -49,7 +49,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
'SELF_CLEARED', // Path A — Operations approved self-clearance; customer may book
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
export type ContractClearanceStatusValue =