contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -0,0 +1,528 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { Readable } from 'stream';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
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 { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { Contract } from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
);
}
}
@Injectable()
export class ContractTransitionService {
private readonly logger = new Logger(ContractTransitionService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
private readonly approvalRulesService: ApprovalRulesService,
private readonly cargoTypesService: CargoTypesService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
async submit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
await this.pricingService.generatePrice(contractId);
await this.pricingService.freezeRateSnapshots(contractId);
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
}
/** Confirm a price change before submit (mirrors booking confirm-submit). */
async confirmSubmit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
await this.pricingService.generatePrice(contractId);
await this.pricingService.freezeRateSnapshots(contractId);
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
}
/**
* Line staff accepts intake: set the validity window from validityDays and
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
*/
async staffAccept(
contractId: string,
actorId: string,
validityDays: number,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this contract.',
);
}
const validFrom = new Date();
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.instantiateApprovalSteps(contract);
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.contractsService.findById(contractId);
}
/**
* Build contract approval steps from the system approval_rules chain (US-06:
* container → line staff + director; bulk → directors + CEO). Mirrors the
* booking transition's instantiateApprovalSteps but writes contract steps.
*/
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
if ((contract.approvalSteps?.length ?? 0) > 0) return;
const cargoTypeId =
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
// US-06 routing: bulk always needs director approval; container needs it only
// when its cargo type flags it. Resolve the chain via the same approval_rules
// source of truth the booking flow uses (no booking row is created here).
let requiresDirectorApproval = contract.freightType === 'BULK';
if (cargoTypeId) {
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
if (cargoType?.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
for (const rule of chain) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
}
}
async requestChanges(
contractId: string,
note: string,
actorId: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
await this.contractsRepository.createReviewNote(
contractId,
note,
'CHANGES_REQUESTED',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.contractsService.findById(contractId);
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'REJECTION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
return this.contractsService.findById(contractId);
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
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']);
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
}
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
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`);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
if (allDone) {
updates.status = 'APPROVED';
}
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
}
return this.contractsService.findById(contractId);
}
/**
* 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.
*/
async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
const { view } = await this.documentViewModelBuilder.build(contractId);
try {
await this.upsertContractPdf(contractId, 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',
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
return this.contractsService.findById(contractId);
}
/**
* Build the contract PDF view-model and rendered HTML for portal/backoffice
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
* totals). Returns the view-model, the rendered HTML and the signature rows.
*/
async getContractDocumentView(contractId: string): Promise<{
view: ContractViewModel;
html: string;
signatures: ContractViewModel['signatures'];
}> {
const { view } = await this.documentViewModelBuilder.build(contractId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return { view, html, signatures: view.signatures };
}
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
private async upsertContractPdf(
contractId: string,
reference: string,
view: ContractViewModel,
): Promise<FileRecord> {
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'contract',
file,
});
}
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
/* keep original url */
}
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
/** Apply a digital signature row (mirrors booking-contract.service). */
private async applySignature(
contract: Contract,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<void> {
const role = dto.role as ContractSignerRole;
const raw = dto.signatureImageBase64.includes(',')
? dto.signatureImageBase64.split(',')[1]!
: dto.signatureImageBase64;
const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code: `signature_${role.toLowerCase()}`,
file: sigFile,
});
await this.contractsRepository.saveSignature({
contractId: contract.id,
role,
signerDisplayName: dto.signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null,
});
if (options.signerUserId) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
);
}
}
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
async sign(
contractId: string,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (dto.role === 'CUSTOMER') {
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return this.contractsService.findById(contractId);
}
return this.counterSign(contractId, dto, options);
}
/**
* Staff/Director/CEO counter-sign → branch on customs:
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
*/
async counterSign(
contractId: string,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
await this.applySignature(contract, dto, options);
const now = new Date();
const updates: Record<string, unknown> = {
fullyExecutedAt: now,
lockedAt: now,
};
if (contract.customsClearingEnabled) {
// Path B — open a clearance cycle, seed the pre-booking milestones, and
// route the customer to the document upload.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// Path A — transport only; ready for the customer to book.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
await this.contractsRepository.update(contractId, updates as never);
return this.contractsService.findById(contractId);
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);
const reference = await this.generateRenewalReference();
const renewal = await this.contractsRepository.create({
reference,
companyId: source.companyId,
companyProfileId: source.companyProfileId,
isGovernment: source.isGovernment,
governmentInstitution: source.governmentInstitution,
contractKind: source.contractKind,
renewalOfId: source.id,
tradeDirection: source.tradeDirection,
freightType: source.freightType,
serviceTypeId: source.serviceTypeId,
paymentCurrency: source.paymentCurrency,
customsClearingEnabled: source.customsClearingEnabled,
customsClearingAgent: source.customsClearingAgent,
equipmentReturn: source.equipmentReturn,
firstMilePickupAddress: source.firstMilePickupAddress,
firstMilePickupLat: source.firstMilePickupLat,
firstMilePickupLng: source.firstMilePickupLng,
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
lastMileDeliveryLat: source.lastMileDeliveryLat,
lastMileDeliveryLng: source.lastMileDeliveryLng,
isHazardous: source.isHazardous,
isReefer: source.isReefer,
contractType: source.contractType,
versionNumber: (source.versionNumber ?? 1) + 1,
status: 'RENEWAL_DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
void userId;
return this.contractsService.findById(renewal.id);
}
private async generateRenewalReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
}
}