contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -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,
});
}
}