mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
contrat nad booking modification
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -17,7 +18,8 @@ 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 { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
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';
|
||||
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
|
||||
locked: boolean;
|
||||
/** Whether the requesting user is the approver whose turn it is to edit. */
|
||||
editableByMe: boolean;
|
||||
/** Role holding editing rights right now, for "locked because…" messaging. */
|
||||
nextApproverRole: string | null;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Approval chains are configured in IAM position types, so a step's role no
|
||||
* longer maps onto the contract's fixed approver columns. These sets keep those
|
||||
* legacy columns populated for the roles that still correspond to one — both the
|
||||
* original role strings on historical rows and the position types that replaced
|
||||
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
|
||||
* which is the source of truth.
|
||||
*/
|
||||
const LEGACY_STAFF_ROLES = new Set([
|
||||
'LINE_STAFF',
|
||||
'employee',
|
||||
'teamLeader',
|
||||
'officeHead',
|
||||
'recordOfficer',
|
||||
]);
|
||||
const LEGACY_DIRECTOR_ROLES = new Set([
|
||||
'DIRECTOR',
|
||||
'director',
|
||||
'operation-director',
|
||||
]);
|
||||
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
@@ -87,6 +115,7 @@ export class ContractTransitionService {
|
||||
private readonly logger = new Logger(ContractTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
@@ -229,18 +258,22 @@ export class ContractTransitionService {
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const editableByMe = await this.documentIsEditableBy(contract, user);
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
locked: !editableByMe,
|
||||
editableByMe,
|
||||
nextApproverRole: await this.nextApproverRole(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
@@ -255,10 +288,12 @@ export class ContractTransitionService {
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
user?: TCurrentUser | null,
|
||||
actorId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.assertDocumentEditable(contract, user);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
@@ -270,9 +305,25 @@ export class ContractTransitionService {
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
const next = this.normalizeSnapshot(merged);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
documentSnapshot: next,
|
||||
} as never);
|
||||
|
||||
// Audit the edit after it lands. Recording history must never break the
|
||||
// edit itself, so the history service swallows its own failures.
|
||||
const step = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contractId,
|
||||
);
|
||||
await this.documentHistory.record({
|
||||
contractId,
|
||||
before: current,
|
||||
after: next,
|
||||
actorId: actorId ?? null,
|
||||
actorRole: step?.requiredRole ?? null,
|
||||
stepId: step?.id ?? null,
|
||||
});
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -334,23 +385,54 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The contract document stays editable for the whole approval chain, but only
|
||||
* by the approver whose turn it is: whoever can action the next pending step.
|
||||
* Approving therefore hands editing rights to the next approver in the chain.
|
||||
*
|
||||
* Edits never reset approvals already given — earlier approvers stay approved.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
private async documentIsEditableBy(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<boolean> {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
if (!next) return false;
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
assertCanApproveContractStep(user, next.requiredRole);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
/** The role that currently holds editing rights, for UI messaging. */
|
||||
private async nextApproverRole(contract: Contract): Promise<string | null> {
|
||||
if (contract.status !== 'PENDING_APPROVAL') return null;
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
return next?.requiredRole ?? null;
|
||||
}
|
||||
|
||||
private async assertDocumentEditable(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<void> {
|
||||
if (await this.documentIsEditableBy(contract, user)) return;
|
||||
|
||||
const role = await this.nextApproverRole(contract);
|
||||
throw new ConflictException(
|
||||
role
|
||||
? `The contract document can only be edited by the current approver (${role}).`
|
||||
: 'The contract document is locked — the contract has advanced beyond approval.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -513,25 +595,11 @@ export class ContractTransitionService {
|
||||
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']);
|
||||
|
||||
// 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');
|
||||
@@ -541,31 +609,34 @@ export class ContractTransitionService {
|
||||
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`);
|
||||
|
||||
// The role is the step's own — never the caller's claim about themselves.
|
||||
const requiredRole = step.requiredRole;
|
||||
if (authUser) {
|
||||
assertCanApproveContractStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
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.
|
||||
// approving one step must not finalize the chain while later steps 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 approver can act.
|
||||
//
|
||||
// `contract_approval_steps` is the source of truth for who approved what — a
|
||||
// chain is an arbitrary sequence of position types and cannot be represented
|
||||
// by fixed columns. The legacy columns below are still stamped, best-effort,
|
||||
// for the three roles that map onto them so older readers keep working.
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
@@ -579,14 +650,19 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// 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.
|
||||
// Final approval is what produces the contract PDF — until now there was
|
||||
// only a live preview. The approval steps are already committed, so a
|
||||
// render failure must not roll them back; surface it instead of swallowing
|
||||
// it, since an APPROVED contract with no document needs operator action.
|
||||
try {
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
this.logger.error(
|
||||
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'All approvals were recorded, but generating the contract PDF failed. ' +
|
||||
'Retry generation from the contract page.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -594,24 +670,13 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Retry path for a contract that finished approval but whose PDF failed to
|
||||
* render (Chromium unavailable, etc.). The normal flow generates the document
|
||||
* automatically on the final approval — there is no manual generate step
|
||||
* before that, only the live preview.
|
||||
*/
|
||||
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, {
|
||||
@@ -626,11 +691,17 @@ export class ContractTransitionService {
|
||||
* 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> {
|
||||
private async renderContractDocument(
|
||||
contract: Contract,
|
||||
options: { strict?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
// Strict callers (final approval) need to know the PDF is missing — it is
|
||||
// the artifact of the completed chain, not a cache that can refill later.
|
||||
if (options.strict) throw err;
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
@@ -642,15 +713,14 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
|
||||
* This is the only point at which the document is produced: approvers review a
|
||||
* live preview, and the final approval is what turns it into a PDF. Renders
|
||||
* unconditionally so the file reflects every edit made during the chain.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.renderContractDocument(contract, { strict: true });
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
|
||||
Reference in New Issue
Block a user