mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user