mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { Readable } from 'stream';
|
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
|
|
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
|
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
|
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
|
import { MinioService } from '../minio/minio.service';
|
|
import { FileRecord } from '../files/entities/file.entity';
|
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
|
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
|
import { FilesService } from '../files/files.service';
|
|
import { SignaturesService } from '../signatures/signatures.service';
|
|
import { OtpService } from '../otp/otp.service';
|
|
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';
|
|
|
|
/**
|
|
* Dropdown-settings code holding the admin-configured contract validity options
|
|
* (each option's `value` is a day count). The staff accept dialog reads the same
|
|
* code, so accept can only use a configured duration. See the seed migration
|
|
* `SeedContractValidityPeriods`.
|
|
*/
|
|
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
|
|
|
/** Status-machine guard mirroring booking-status.util. */
|
|
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
|
if (!allowed.includes(contract.status)) {
|
|
throw new ConflictException(
|
|
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
export class ContractTransitionService {
|
|
private readonly logger = new Logger(ContractTransitionService.name);
|
|
|
|
constructor(
|
|
private readonly contractsRepository: ContractsRepository,
|
|
private readonly contractsService: ContractsService,
|
|
private readonly pricingService: ContractPricingService,
|
|
private readonly approvalRulesService: ApprovalRulesService,
|
|
private readonly cargoTypesService: CargoTypesService,
|
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
|
private readonly filesService: FilesService,
|
|
private readonly signaturesService: SignaturesService,
|
|
private readonly milestoneService: ClearanceMilestoneService,
|
|
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
|
private readonly renderer: ContractRendererService,
|
|
private readonly pdfService: ContractPdfService,
|
|
private readonly minioService: MinioService,
|
|
private readonly otpService: OtpService,
|
|
) {}
|
|
|
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
|
async submit(contractId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
|
|
|
|
await this.pricingService.generatePrice(contractId);
|
|
await this.pricingService.freezeRateSnapshots(contractId);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'SUBMITTED',
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
|
async confirmSubmit(contractId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
|
|
|
await this.pricingService.generatePrice(contractId);
|
|
await this.pricingService.freezeRateSnapshots(contractId);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'SUBMITTED',
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Line staff accepts intake: set the validity window from validityDays and
|
|
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
|
|
*/
|
|
async staffAccept(
|
|
contractId: string,
|
|
actorId: string,
|
|
validityDays: number,
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SUBMITTED']);
|
|
|
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
|
throw new BadRequestException(
|
|
'A contract validity (in days) is required to accept this contract.',
|
|
);
|
|
}
|
|
|
|
await this.assertValidityDaysConfigured(validityDays);
|
|
|
|
const validFrom = new Date();
|
|
const validUntil = new Date(validFrom);
|
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
|
|
|
await this.instantiateApprovalSteps(contract);
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'PENDING_APPROVAL',
|
|
approvedByStaffId: actorId,
|
|
approvedByStaffAt: validFrom,
|
|
contractValidityDays: validityDays,
|
|
contractValidFrom: validFrom,
|
|
contractValidUntil: validUntil,
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Ensure the chosen validity (days) is one of the admin-configured options in
|
|
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
|
* or has no options yet, fall back to the DTO range check (already applied) so
|
|
* acceptance is never hard-blocked before an admin configures the list.
|
|
*/
|
|
private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
|
|
let setting;
|
|
try {
|
|
setting = await this.dropdownSettingsService.getByCode(
|
|
CONTRACT_VALIDITY_PERIODS_CODE,
|
|
);
|
|
} catch {
|
|
// Not configured yet — keep the flow working with the DTO range only.
|
|
return;
|
|
}
|
|
|
|
const allowed = (setting.children ?? [])
|
|
.map((o) => Number(o.value))
|
|
.filter((n) => Number.isFinite(n));
|
|
if (allowed.length === 0) return;
|
|
|
|
if (!allowed.includes(validityDays)) {
|
|
throw new BadRequestException(
|
|
`Validity ${validityDays} days is not a configured option. Allowed: ${allowed
|
|
.sort((a, b) => a - b)
|
|
.join(', ')} days.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build contract approval steps from the system approval_rules chain (US-06:
|
|
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
|
* booking transition's instantiateApprovalSteps but writes contract steps.
|
|
*/
|
|
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
|
|
if ((contract.approvalSteps?.length ?? 0) > 0) return;
|
|
|
|
const cargoTypeId =
|
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
|
|
|
// Resolve the chain from the cargo type flag only.
|
|
let requiresDirectorApproval = false;
|
|
if (cargoTypeId) {
|
|
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
|
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
|
}
|
|
|
|
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
|
if (chain.length === 0) {
|
|
throw new BadRequestException(
|
|
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
|
);
|
|
}
|
|
|
|
for (const rule of chain) {
|
|
await this.contractsRepository.createApprovalStep({
|
|
contractId: contract.id,
|
|
stepOrder: rule.stepOrder,
|
|
requiredRole: rule.requiredRole,
|
|
blocksRole: rule.blocksRole ?? null,
|
|
status: 'PENDING',
|
|
});
|
|
}
|
|
}
|
|
|
|
async requestChanges(
|
|
contractId: string,
|
|
note: string,
|
|
actorId: string,
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SUBMITTED']);
|
|
|
|
await this.contractsRepository.createReviewNote(
|
|
contractId,
|
|
note,
|
|
'CHANGES_REQUESTED',
|
|
actorId,
|
|
'STAFF',
|
|
);
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'CHANGES_REQUESTED',
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
|
|
|
await this.contractsRepository.createReviewNote(
|
|
contractId,
|
|
reason,
|
|
'REJECTION',
|
|
actorId,
|
|
'STAFF',
|
|
);
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'REJECTED',
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
|
async approveStep(
|
|
contractId: string,
|
|
stepId: string,
|
|
actorId: string,
|
|
requiredRole: string,
|
|
authUser?: TCurrentUser,
|
|
): Promise<Contract> {
|
|
if (authUser) {
|
|
assertCanApproveBookingStep(authUser, requiredRole);
|
|
}
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
|
|
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
|
if (!step || step.status !== 'PENDING') {
|
|
throw new BadRequestException('Approval step not found or already actioned');
|
|
}
|
|
|
|
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
|
if (!next || next.id !== step.id) {
|
|
throw new BadRequestException('Approval steps must be completed in order');
|
|
}
|
|
if (step.requiredRole !== requiredRole) {
|
|
throw new BadRequestException(
|
|
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
|
);
|
|
}
|
|
if (step.blocksRole && step.blocksRole === requiredRole) {
|
|
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
|
}
|
|
|
|
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
|
|
|
// Record who acted on this step, but DO NOT advance the contract status here —
|
|
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
|
|
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
|
|
// every step in the chain is complete; until then the contract stays in
|
|
// PENDING_APPROVAL so the next required role can act.
|
|
const updates: Record<string, unknown> = {};
|
|
const now = new Date();
|
|
if (requiredRole === 'LINE_STAFF') {
|
|
updates.approvedByStaffId = actorId;
|
|
updates.approvedByStaffAt = now;
|
|
} else if (requiredRole === 'DIRECTOR') {
|
|
updates.signedByDirectorId = actorId;
|
|
updates.signedByDirectorAt = now;
|
|
} else if (requiredRole === 'CEO') {
|
|
updates.signedByCeoId = actorId;
|
|
updates.signedByCeoAt = now;
|
|
}
|
|
|
|
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
|
|
updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await this.contractsRepository.update(contractId, updates as never);
|
|
}
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
|
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
|
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
|
* the document is (re)rendered lazily on view/download once Chromium is available.
|
|
*/
|
|
async generateContract(contractId: string): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
|
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
|
|
try {
|
|
await this.upsertContractPdf(contractId, contract.reference, view);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
|
);
|
|
}
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'CONTRACT_READY',
|
|
contractTemplateKey: view.templateKey,
|
|
contractGeneratedAt: new Date(),
|
|
} as never);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/**
|
|
* Build the contract PDF view-model and rendered HTML for portal/backoffice
|
|
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
|
|
* totals). Returns the view-model, the rendered HTML and the signature rows.
|
|
*/
|
|
async getContractDocumentView(contractId: string): Promise<{
|
|
view: ContractViewModel;
|
|
html: string;
|
|
signatures: ContractViewModel['signatures'];
|
|
}> {
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
await this.inlineSignatureImages(view.signatures);
|
|
const html = this.renderer.render(view);
|
|
return { view, html, signatures: view.signatures };
|
|
}
|
|
|
|
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
|
|
async streamContractPdf(contractId: string) {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
const record = await this.upsertContractPdf(contractId, contract.reference, view);
|
|
return this.filesService.streamById(record.id);
|
|
}
|
|
|
|
/**
|
|
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
|
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
|
* view. Best-effort — a Chromium hiccup must never fail the signing
|
|
* transaction; the doc still re-renders live on /contract/view.
|
|
*/
|
|
private async regenerateContractPdf(
|
|
contractId: string,
|
|
reference: string,
|
|
): Promise<void> {
|
|
try {
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
await this.upsertContractPdf(contractId, reference, view);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
|
|
private async upsertContractPdf(
|
|
contractId: string,
|
|
reference: string,
|
|
view: ContractViewModel,
|
|
): Promise<FileRecord> {
|
|
await this.inlineSignatureImages(view.signatures);
|
|
const html = this.renderer.render(view);
|
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
|
const file: Express.Multer.File = {
|
|
fieldname: 'contract',
|
|
originalname: `contract-${reference}.pdf`,
|
|
encoding: '7bit',
|
|
mimetype: 'application/pdf',
|
|
size: pdfBuffer.length,
|
|
buffer: pdfBuffer,
|
|
stream: Readable.from(pdfBuffer),
|
|
destination: '',
|
|
filename: '',
|
|
path: '',
|
|
};
|
|
|
|
return this.filesService.upsertByCode({
|
|
resourceId: contractId,
|
|
resource: 'contracts',
|
|
code: 'contract',
|
|
file,
|
|
});
|
|
}
|
|
|
|
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
|
private async inlineSignatureImages(
|
|
signatures: Array<{ signatureImageUrl?: string | null }>,
|
|
): Promise<void> {
|
|
for (const sig of signatures) {
|
|
if (!sig.signatureImageUrl) continue;
|
|
try {
|
|
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
|
const objectName = this.minioService.getObjectNameFromUrl(
|
|
sig.signatureImageUrl,
|
|
);
|
|
const stream = await this.minioService.getFileStream(objectName);
|
|
const buffer = await this.streamToBuffer(stream);
|
|
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
|
} catch {
|
|
/* keep original url */
|
|
}
|
|
}
|
|
}
|
|
|
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on('data', (chunk: Buffer | string) => {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
});
|
|
stream.on('error', reject);
|
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
});
|
|
}
|
|
|
|
/** Apply a digital signature row (mirrors booking-contract.service). */
|
|
private async applySignature(
|
|
contract: Contract,
|
|
dto: SignContractDto,
|
|
options: { signerUserId?: string },
|
|
): Promise<void> {
|
|
const role = dto.role as ContractSignerRole;
|
|
|
|
// Resolve the signature image. The client may send a freshly-drawn image, or
|
|
// omit it to reuse the signer's saved profile signature. Fall back to the
|
|
// saved one whenever no image is supplied.
|
|
let imageBase64 = dto.signatureImageBase64;
|
|
let signerDisplayName = dto.signerDisplayName;
|
|
if (!imageBase64 && options.signerUserId) {
|
|
const saved = await this.signaturesService.getForUser(options.signerUserId);
|
|
if (saved?.signatureImageUrl) {
|
|
imageBase64 = saved.signatureImageUrl;
|
|
signerDisplayName = signerDisplayName || saved.signerDisplayName;
|
|
}
|
|
}
|
|
if (!imageBase64) {
|
|
throw new BadRequestException(
|
|
'No signature provided and no saved signature found on the profile.',
|
|
);
|
|
}
|
|
|
|
const raw = imageBase64.includes(',')
|
|
? imageBase64.split(',')[1]!
|
|
: imageBase64;
|
|
const buffer = Buffer.from(raw, 'base64');
|
|
const sigFile: Express.Multer.File = {
|
|
fieldname: `signature_${role.toLowerCase()}`,
|
|
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
|
encoding: '7bit',
|
|
mimetype: 'image/png',
|
|
size: buffer.length,
|
|
buffer,
|
|
stream: Readable.from(buffer),
|
|
destination: '',
|
|
filename: '',
|
|
path: '',
|
|
};
|
|
|
|
const fileRecord = await this.filesService.upsertByCode({
|
|
resourceId: contract.id,
|
|
resource: 'contracts',
|
|
code: `signature_${role.toLowerCase()}`,
|
|
file: sigFile,
|
|
});
|
|
|
|
await this.contractsRepository.saveSignature({
|
|
contractId: contract.id,
|
|
role,
|
|
signerDisplayName,
|
|
signedAt: new Date(),
|
|
signatureFileId: fileRecord.id,
|
|
consentText: dto.consentText ?? null,
|
|
});
|
|
|
|
// Only (re)save the reusable profile signature when the signer drew a NEW
|
|
// image. Reusing the saved signature must not rewrite it with itself.
|
|
if (options.signerUserId && dto.signatureImageBase64) {
|
|
try {
|
|
await this.signaturesService.upsertForUser({
|
|
userId: options.signerUserId,
|
|
signerDisplayName,
|
|
signatureImageBase64: dto.signatureImageBase64,
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
|
async sign(
|
|
contractId: string,
|
|
dto: SignContractDto,
|
|
options: { signerUserId?: string },
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
if (dto.role === 'CUSTOMER') {
|
|
assertContractStatus(contract, ['CONTRACT_READY']);
|
|
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
|
if (existing) {
|
|
throw new BadRequestException('Customer has already signed this contract');
|
|
}
|
|
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
|
// must be verified before the signature is applied.
|
|
if (!dto.otpPhone || !dto.otp) {
|
|
throw new BadRequestException('OTP verification is required to sign the contract');
|
|
}
|
|
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
|
|
await this.applySignature(contract, dto, options);
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'SIGNED_CUSTOMER',
|
|
customerSignedAt: new Date(),
|
|
} as never);
|
|
await this.regenerateContractPdf(contractId, contract.reference);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
return this.counterSign(contractId, dto, options);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
dto: SignContractDto,
|
|
options: { signerUserId?: string },
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
|
|
|
await this.applySignature(contract, dto, options);
|
|
|
|
const now = new Date();
|
|
const updates: Record<string, unknown> = {
|
|
fullyExecutedAt: now,
|
|
lockedAt: now,
|
|
};
|
|
|
|
// 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,
|
|
);
|
|
|
|
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
|
// level: there is no contract clearance cycle. The contract just becomes
|
|
// active; the customer then files shipment requests and GL books + clears
|
|
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
|
// cycle below.
|
|
const isGeneralCustoms =
|
|
contract.contractKind === 'GENERAL' &&
|
|
Boolean(contract.customsClearingEnabled);
|
|
|
|
if (clearanceCode && !isGeneralCustoms) {
|
|
// 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);
|
|
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
|
updates.clearanceCycleNumber = cycleNumber;
|
|
} else {
|
|
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
|
// clears per booking). Ready for shipment requests / direct booking.
|
|
updates.status =
|
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
|
}
|
|
|
|
await this.contractsRepository.update(contractId, updates as never);
|
|
await this.regenerateContractPdf(contractId, contract.reference);
|
|
return this.contractsService.findById(contractId);
|
|
}
|
|
|
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
|
const source = await this.contractsService.findById(contractId);
|
|
|
|
const reference = await this.generateRenewalReference();
|
|
const renewal = await this.contractsRepository.create({
|
|
reference,
|
|
companyId: source.companyId,
|
|
companyProfileId: source.companyProfileId,
|
|
isGovernment: source.isGovernment,
|
|
governmentInstitution: source.governmentInstitution,
|
|
contractKind: source.contractKind,
|
|
renewalOfId: source.id,
|
|
tradeDirection: source.tradeDirection,
|
|
freightType: source.freightType,
|
|
serviceTypeId: source.serviceTypeId,
|
|
paymentCurrency: source.paymentCurrency,
|
|
customsClearingEnabled: source.customsClearingEnabled,
|
|
customsClearingAgent: source.customsClearingAgent,
|
|
equipmentReturn: source.equipmentReturn,
|
|
firstMilePickupAddress: source.firstMilePickupAddress,
|
|
firstMilePickupLat: source.firstMilePickupLat,
|
|
firstMilePickupLng: source.firstMilePickupLng,
|
|
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
|
|
lastMileDeliveryLat: source.lastMileDeliveryLat,
|
|
lastMileDeliveryLng: source.lastMileDeliveryLng,
|
|
isHazardous: source.isHazardous,
|
|
isReefer: source.isReefer,
|
|
contractType: source.contractType,
|
|
versionNumber: (source.versionNumber ?? 1) + 1,
|
|
status: 'RENEWAL_DRAFT',
|
|
clearanceStatus: 'NOT_APPLICABLE',
|
|
clearanceCycleNumber: 0,
|
|
} as never);
|
|
|
|
void userId;
|
|
return this.contractsService.findById(renewal.id);
|
|
}
|
|
|
|
private async generateRenewalReference(): Promise<string> {
|
|
const year = new Date().getFullYear();
|
|
const count = await this.contractsRepository.countByYear(year);
|
|
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
|
|
}
|
|
}
|