mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
540 lines
19 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}
|