mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user