Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts

540 lines
19 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
export interface ContractClearanceDocument {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: ContractDocReviewStatus | null;
note: string | null;
/** When the review decision (approve/query) was recorded. */
reviewedAt: string | null;
/** Staff id that recorded the decision (no user directory to resolve names). */
reviewedByStaffId: string | null;
}
export interface ContractClearanceView {
contractId: string;
status: string;
clearanceStatus: string;
cycleNumber: number;
includesCustoms: boolean;
inputCode: string | null;
outputCode: string | null;
documents: ContractClearanceDocument[];
allApproved: boolean;
}
@Injectable()
export class ContractClearanceService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
) {}
/** The pre-booking clearance document grid for a contract (Path B). */
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
const contract = await this.contractsService.findById(contractId);
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
const files = await this.filesService.findByResource(contractId, 'contracts');
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const documents: ContractClearanceDocument[] = [];
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return; // setting not seeded — skip gracefully
}
for (const field of setting.fields ?? []) {
const file = fileByCode.get(field.fileKey) ?? null;
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
documents.push({
fileKey: field.fileKey,
label: field.fileLabel,
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
const allApproved = await this.isClearanceFullyApproved(contract);
return {
contractId,
status: contract.status,
clearanceStatus: contract.clearanceStatus,
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
};
}
/**
* True when every REQUIRED customer-input field has an APPROVED review row in
* the current cycle. The 100% gate before clearance can be finalized.
*/
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
const { inputCode } = contractClearanceCodes(contract);
if (!inputCode) return true;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return false;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const cycle = await this.contractsRepository.currentCycle(contract.id);
const reviews = await this.contractsRepository.findDocumentReviews(
contract.id,
cycle?.id ?? null,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
),
);
}
/**
* Customer uploads clearance documents on the contract. When every required
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
*/
async uploadDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'
) {
throw new ConflictException(
`Cannot upload clearance documents on status "${contract.status}".`,
);
}
const { inputCode } = contractClearanceCodes(contract);
if (!inputCode) {
throw new BadRequestException('This contract has no document-clearance step');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
// First submission: every required input field must be present.
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
await this.assertRequiredInputsPresent(contractId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
await this.contractsRepository.upsertDocumentReviewPending({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey: file.fieldname,
fileRecordId: record.id,
uploadedByRole: 'CUSTOMER',
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
return this.contractsService.findById(contractId);
}
private async assertRequiredInputsPresent(
contractId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(contractId, 'contracts');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/**
* 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);
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
// the contract to "awaiting" (the customer must re-upload), but the reviewer
// may still be working through the rest of the batch. Restricting to
// UNDER_REVIEW only would 409 every review after the first query.
if (
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);
}
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
}
const { inputCode, outputCode } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const match = reviews.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
await this.contractsRepository.setDocumentReviewStatus({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey,
status,
staffId,
note,
});
if (status === 'QUERIED') {
await this.contractsRepository.createReviewNote(
contractId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
reviewerRole,
);
// Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
}
return this.contractsService.findById(contractId);
}
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
async uploadOutputDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot upload output documents on status "${contract.status}".`,
);
}
const { outputCode } = contractClearanceCodes(contract);
if (!outputCode) {
throw new BadRequestException('This contract has no customs output documents');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
}
return this.contractsService.findById(contractId);
}
/**
* 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}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const { outputCode } = contractClearanceCodes(contract);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(contractId, 'contracts');
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
);
if (missing.length > 0) {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
);
}
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_READY_FOR_BOOKING',
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(
cycle.id,
'CLEARANCE_READY_FOR_BOOKING',
{ clearanceReadyAt: new Date() },
);
}
return this.contractsService.findById(contractId);
}
/**
* 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 clearance hub: every customs (Path B) contract that still needs
* customs clearance — awaiting the customer's documents, under GL review, or
* finalized and waiting for the customer to create the booking in the portal.
*/
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
],
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,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: true,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
/** Operations history: contracts that completed Path A self-clearance review. */
async opsHistory(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: false,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
}