mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 04:48:18 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
|
||||
async reviewDocument(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
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,
|
||||
'GL_ET',
|
||||
);
|
||||
// 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 pre-booking clearance: requires every customer document
|
||||
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
|
||||
* CLEARANCE_UNDER_REVIEW (customs contracts only).
|
||||
*/
|
||||
async queue(
|
||||
filter: FilterContractDto,
|
||||
region?: string,
|
||||
): Promise<PaginatedContracts> {
|
||||
void region; // single ET pre-booking queue today; region reserved for split
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user