This commit is contained in:
Marshal
2026-07-15 13:29:01 +00:00
parent c71a0043d6
commit 19c9da28ae
59 changed files with 3008 additions and 284 deletions

View File

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