contrat nad booking modification

This commit is contained in:
Marshal
2026-07-20 12:24:58 +00:00
parent eb532399d9
commit b90afadfba
55 changed files with 1210 additions and 1373 deletions

View File

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