|
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
|
|
Injectable,
|
|
|
|
|
Logger,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { Readable } from 'stream';
|
|
|
|
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
|
|
|
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
|
|
|
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
|
|
|
|
|
import { FilesService } from '../files/files.service';
|
|
|
|
|
import { SignaturesService } from '../signatures/signatures.service';
|
|
|
|
|
import { OtpService } from '../otp/otp.service';
|
|
|
|
|
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
|
|
|
|
import { ContractPricingService } from './contract-pricing.service';
|
|
|
|
|
import { ContractNotifierService } from './contract-notifier.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 {
|
|
|
|
|
Contract,
|
|
|
|
|
ContractDocumentArticle,
|
|
|
|
|
ContractDocumentSnapshot,
|
|
|
|
|
ContractDocumentSnapshotInput,
|
|
|
|
|
} from './entities/contract.entity';
|
|
|
|
|
import { ContractSignerRole } from './entities/contract-signature.entity';
|
|
|
|
|
import { SignContractDto } from './dto/sign-contract.dto';
|
|
|
|
|
|
|
|
|
|
/** The editable contract-document draft returned for the accept/edit dialog. */
|
|
|
|
|
export interface ContractDocumentDraft {
|
|
|
|
|
documentTitle: string | null;
|
|
|
|
|
whereasClauses: string[];
|
|
|
|
|
articles: ContractDocumentArticle[];
|
|
|
|
|
code: string | null;
|
|
|
|
|
name: string | null;
|
|
|
|
|
/** True once the document may no longer be edited/regenerated. */
|
|
|
|
|
locked: boolean;
|
|
|
|
|
generatedAt: Date | null;
|
|
|
|
|
status: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
@@ -68,6 +88,7 @@ export class ContractTransitionService {
|
|
|
|
|
private readonly minioService: MinioService,
|
|
|
|
|
private readonly otpService: OtpService,
|
|
|
|
|
private readonly notifier: ContractNotifierService,
|
|
|
|
|
private readonly contractTemplates: ContractTemplatesService,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
|
|
|
|
@@ -110,6 +131,7 @@ export class ContractTransitionService {
|
|
|
|
|
contractId: string,
|
|
|
|
|
actorId: string,
|
|
|
|
|
validityDays: number,
|
|
|
|
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
|
|
|
|
): Promise<Contract> {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
assertContractStatus(contract, ['SUBMITTED']);
|
|
|
|
|
@@ -128,6 +150,12 @@ export class ContractTransitionService {
|
|
|
|
|
|
|
|
|
|
await this.instantiateApprovalSteps(contract);
|
|
|
|
|
|
|
|
|
|
// Freeze the contract document for THIS contract only. Staff may have edited
|
|
|
|
|
// the articles in the accept dialog; otherwise the live template is captured
|
|
|
|
|
// as-is so later template edits never change an in-flight contract. The
|
|
|
|
|
// shared six templates are never written here.
|
|
|
|
|
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
|
|
|
|
|
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
|
|
|
status: 'PENDING_APPROVAL',
|
|
|
|
|
approvedByStaffId: actorId,
|
|
|
|
|
@@ -135,12 +163,148 @@ export class ContractTransitionService {
|
|
|
|
|
contractValidityDays: validityDays,
|
|
|
|
|
contractValidFrom: validFrom,
|
|
|
|
|
contractValidUntil: validUntil,
|
|
|
|
|
documentSnapshot: snapshot,
|
|
|
|
|
} as never);
|
|
|
|
|
const updated = await this.contractsService.findById(contractId);
|
|
|
|
|
this.notifier.accepted(updated);
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
|
|
|
|
* if one exists, else the live active template resolved for this contract's
|
|
|
|
|
* direction/freight pair. `locked` flips true once the document may no longer
|
|
|
|
|
* be edited (an approver has acted, or the contract has left the pre-approval
|
|
|
|
|
* window).
|
|
|
|
|
*/
|
|
|
|
|
async getContractDocumentDraft(
|
|
|
|
|
contractId: string,
|
|
|
|
|
): Promise<ContractDocumentDraft> {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
const snapshot =
|
|
|
|
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
|
|
|
|
(await this.resolveDocumentSnapshot(contract));
|
|
|
|
|
return {
|
|
|
|
|
documentTitle: snapshot?.documentTitle ?? null,
|
|
|
|
|
whereasClauses: snapshot?.whereasClauses ?? [],
|
|
|
|
|
articles: snapshot?.articles ?? [],
|
|
|
|
|
code: snapshot?.code ?? null,
|
|
|
|
|
name: snapshot?.name ?? null,
|
|
|
|
|
locked: !this.documentIsEditable(contract),
|
|
|
|
|
generatedAt: contract.contractGeneratedAt ?? null,
|
|
|
|
|
status: contract.status,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Replace this contract's document articles from the editor. Per-contract
|
|
|
|
|
* only — it writes the contract's own snapshot and never the shared templates.
|
|
|
|
|
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
|
|
|
|
* has acted).
|
|
|
|
|
*/
|
|
|
|
|
async updateContractDocument(
|
|
|
|
|
contractId: string,
|
|
|
|
|
input: ContractDocumentSnapshotInput,
|
|
|
|
|
): Promise<Contract> {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
|
|
|
|
this.assertDocumentEditable(contract);
|
|
|
|
|
|
|
|
|
|
const current =
|
|
|
|
|
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
|
|
|
|
(await this.resolveDocumentSnapshot(contract));
|
|
|
|
|
const merged: ContractDocumentSnapshotInput = {
|
|
|
|
|
code: current?.code ?? null,
|
|
|
|
|
name: input.name ?? current?.name ?? null,
|
|
|
|
|
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
|
|
|
|
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
|
|
|
|
articles: input.articles ?? current?.articles ?? [],
|
|
|
|
|
};
|
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
|
|
|
documentSnapshot: this.normalizeSnapshot(merged),
|
|
|
|
|
} as never);
|
|
|
|
|
return this.contractsService.findById(contractId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
|
|
|
|
* from the dialog; otherwise freeze the active template matching the
|
|
|
|
|
* contract's direction/freight. Returns null when no active template exists
|
|
|
|
|
* (the renderer then falls back to the built-in generic layout at render time).
|
|
|
|
|
*/
|
|
|
|
|
private async resolveDocumentSnapshot(
|
|
|
|
|
contract: Contract,
|
|
|
|
|
provided?: ContractDocumentSnapshotInput | null,
|
|
|
|
|
): Promise<ContractDocumentSnapshot | null> {
|
|
|
|
|
if (provided && (provided.articles?.length ?? 0) > 0) {
|
|
|
|
|
return this.normalizeSnapshot(provided);
|
|
|
|
|
}
|
|
|
|
|
const active = await this.contractTemplates.findActiveForContract(
|
|
|
|
|
contract.tradeDirection,
|
|
|
|
|
contract.freightType,
|
|
|
|
|
);
|
|
|
|
|
if (!active) return null;
|
|
|
|
|
return {
|
|
|
|
|
code: active.code,
|
|
|
|
|
name: active.name,
|
|
|
|
|
documentTitle: active.documentTitle,
|
|
|
|
|
whereasClauses: active.whereasClauses ?? [],
|
|
|
|
|
articles: this.normalizeArticles(active.articles ?? []),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private normalizeSnapshot(
|
|
|
|
|
input: ContractDocumentSnapshotInput,
|
|
|
|
|
): ContractDocumentSnapshot {
|
|
|
|
|
return {
|
|
|
|
|
code: input.code ?? null,
|
|
|
|
|
name: input.name ?? null,
|
|
|
|
|
documentTitle: input.documentTitle ?? null,
|
|
|
|
|
whereasClauses: Array.isArray(input.whereasClauses)
|
|
|
|
|
? input.whereasClauses
|
|
|
|
|
.map((c) => String(c))
|
|
|
|
|
.filter((c) => c.trim().length > 0)
|
|
|
|
|
: [],
|
|
|
|
|
articles: this.normalizeArticles(input.articles ?? []),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
|
|
|
|
private normalizeArticles(
|
|
|
|
|
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
|
|
|
|
): ContractDocumentArticle[] {
|
|
|
|
|
return articles
|
|
|
|
|
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
|
|
|
|
.map((a, index) => ({
|
|
|
|
|
id: a.id ?? randomUUID(),
|
|
|
|
|
title: (a.title ?? '').trim(),
|
|
|
|
|
body: a.body ?? '',
|
|
|
|
|
order: index + 1,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The per-contract document may be edited/regenerated while the contract is at
|
|
|
|
|
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
|
|
|
|
* yet. The first approval action freezes it.
|
|
|
|
|
*/
|
|
|
|
|
private documentIsEditable(contract: Contract): boolean {
|
|
|
|
|
if (contract.status === 'SUBMITTED') return true;
|
|
|
|
|
if (contract.status !== 'PENDING_APPROVAL') return false;
|
|
|
|
|
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private assertDocumentEditable(contract: Contract): void {
|
|
|
|
|
if (!this.documentIsEditable(contract)) {
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
'The contract document is locked — an approver has already acted or the ' +
|
|
|
|
|
'contract has advanced. It can no longer be edited or regenerated.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure the chosen validity (days) is one of the admin-configured options in
|
|
|
|
|
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
|
|
|
|
@@ -303,6 +467,15 @@ export class ContractTransitionService {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
|
|
|
|
|
|
|
|
|
// Approvers review the generated contract document, so it must exist before
|
|
|
|
|
// the first approval can be recorded. Staff generate it (from the frozen,
|
|
|
|
|
// optionally-edited snapshot) at the accept stage.
|
|
|
|
|
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Generate the contract document before it can be approved.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
|
|
|
|
if (!step || step.status !== 'PENDING') {
|
|
|
|
|
throw new BadRequestException('Approval step not found or already actioned');
|
|
|
|
|
@@ -350,15 +523,14 @@ export class ContractTransitionService {
|
|
|
|
|
const updated = await this.contractsService.findById(contractId);
|
|
|
|
|
if (allDone) {
|
|
|
|
|
this.notifier.approved(updated);
|
|
|
|
|
// Final approval step also generates the contract document from the
|
|
|
|
|
// template matching the contract's direction/freight pair. Best-effort:
|
|
|
|
|
// a rendering hiccup must not roll back the approval — the document can
|
|
|
|
|
// still be generated manually or lazily on view/download.
|
|
|
|
|
// Every step approved → CONTRACT_READY. The document was already generated
|
|
|
|
|
// (and reviewed) at the accept stage, so we reuse it rather than
|
|
|
|
|
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
|
|
|
|
try {
|
|
|
|
|
return await this.generateContract(contractId);
|
|
|
|
|
return await this.finalizeApprovedContract(contractId);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
|
|
|
|
|
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -366,30 +538,66 @@ export class ContractTransitionService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* Staff (re)generate the contract PDF. Two stages:
|
|
|
|
|
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
|
|
|
|
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
|
|
|
|
* it is blocked once an approver has acted (the document is then locked).
|
|
|
|
|
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
|
|
|
|
* CONTRACT_READY.
|
|
|
|
|
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
|
|
|
|
* transition — the document re-renders lazily on view/download.
|
|
|
|
|
*/
|
|
|
|
|
async generateContract(contractId: string): Promise<Contract> {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
|
|
|
|
|
if (contract.status === 'PENDING_APPROVAL') {
|
|
|
|
|
this.assertDocumentEditable(contract);
|
|
|
|
|
await this.renderContractDocument(contract);
|
|
|
|
|
return this.contractsService.findById(contractId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
|
|
|
|
await this.renderContractDocument(contract);
|
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
|
|
|
status: 'CONTRACT_READY',
|
|
|
|
|
} as never);
|
|
|
|
|
return this.contractsService.findById(contractId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { view } = await this.documentViewModelBuilder.build(contractId);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
|
|
|
|
* it via FilesService, and stamp the template key + generated timestamp. Never
|
|
|
|
|
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
|
|
|
|
* (it re-renders on view/download) but the timestamp is still stamped.
|
|
|
|
|
*/
|
|
|
|
|
private async renderContractDocument(contract: Contract): Promise<void> {
|
|
|
|
|
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
|
|
|
|
try {
|
|
|
|
|
await this.upsertContractPdf(contractId, contract.reference, view);
|
|
|
|
|
await this.upsertContractPdf(contract.id, 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',
|
|
|
|
|
await this.contractsRepository.update(contract.id, {
|
|
|
|
|
contractTemplateKey: view.templateKey,
|
|
|
|
|
contractGeneratedAt: new Date(),
|
|
|
|
|
} as never);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Every approval step landed → CONTRACT_READY. The document was already
|
|
|
|
|
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
|
|
|
|
* if it was somehow never generated. Never re-renders over an existing file.
|
|
|
|
|
*/
|
|
|
|
|
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
|
|
|
|
const contract = await this.contractsService.findById(contractId);
|
|
|
|
|
if (!contract.contractGeneratedAt) {
|
|
|
|
|
await this.renderContractDocument(contract);
|
|
|
|
|
}
|
|
|
|
|
await this.contractsRepository.update(contractId, {
|
|
|
|
|
status: 'CONTRACT_READY',
|
|
|
|
|
} as never);
|
|
|
|
|
return this.contractsService.findById(contractId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|