mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
forwardRef,
|
||
Inject,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { Readable } from 'stream';
|
||
|
||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||
import { getTemplateMeta } from '../../contracts/contract-template.registry';
|
||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||
import { MinioService } from '../minio/minio.service';
|
||
import { FilesService } from '../files/files.service';
|
||
import { FileRecord } from '../files/entities/file.entity';
|
||
import { BookingsRepository } from './bookings.repository';
|
||
import { Booking } from './entities/booking.entity';
|
||
import { assertBookingStatus } from './booking-status.util';
|
||
import { clearanceSettingCode } from './clearance.util';
|
||
import { ContractViewDto } from './dto/contract-view.dto';
|
||
import { SignContractDto } from './dto/sign-contract.dto';
|
||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||
|
||
/**
|
||
* Default ordering window (months) for a general contract activated on
|
||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||
* defined locally to avoid a circular module dependency on booking-orders.
|
||
*/
|
||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||
import { SignaturesService } from '../signatures/signatures.service';
|
||
|
||
@Injectable()
|
||
export class BookingContractService {
|
||
private readonly logger = new Logger(BookingContractService.name);
|
||
|
||
constructor(
|
||
private readonly bookingsRepository: BookingsRepository,
|
||
private readonly filesService: FilesService,
|
||
private readonly minioService: MinioService,
|
||
private readonly templateResolver: ContractTemplateResolver,
|
||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||
private readonly renderer: ContractRendererService,
|
||
private readonly pdfService: ContractPdfService,
|
||
@Inject(forwardRef(() => BookingBatchService))
|
||
private readonly bookingBatchService: BookingBatchService,
|
||
private readonly signaturesService: SignaturesService,
|
||
) {}
|
||
|
||
buildContractSummary(booking: Booking): string {
|
||
const direction =
|
||
booking.tradeDirection === 'IMPORT'
|
||
? 'Import'
|
||
: booking.tradeDirection === 'EXPORT'
|
||
? 'Export'
|
||
: booking.tradeDirection;
|
||
|
||
const cargo = booking.cargoType;
|
||
const isBulk = booking.freightType === 'BULK';
|
||
|
||
let cargoLabel: string;
|
||
if (isBulk) {
|
||
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
|
||
} else {
|
||
const lines =
|
||
booking.bookingContainers?.map((bc) => {
|
||
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
|
||
return `${bc.quantity}× ${label}`;
|
||
}) ?? [];
|
||
cargoLabel =
|
||
lines.length > 0
|
||
? `Container (${lines.join(', ')})`
|
||
: 'Container (Standard)';
|
||
}
|
||
|
||
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
|
||
}
|
||
|
||
async getSummary(bookingId: string): Promise<{ summary: string }> {
|
||
const booking = await this.requireBooking(bookingId);
|
||
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
|
||
return { summary };
|
||
}
|
||
|
||
async getContractView(
|
||
bookingId: string,
|
||
viewerUserId?: string,
|
||
): Promise<ContractViewDto> {
|
||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||
await this.inlineSignatureImages(view.signatures);
|
||
const html = this.renderer.render(view);
|
||
const savedSignature = viewerUserId
|
||
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||
: undefined;
|
||
return {
|
||
bookingId: view.bookingId,
|
||
reference: view.reference,
|
||
status: view.status,
|
||
templateKey: view.templateKey,
|
||
title: view.template.title,
|
||
html,
|
||
canSignCustomer: view.canSignCustomer,
|
||
canSignStaff: view.canSignStaff,
|
||
hasContractDocument: view.hasContractDocument,
|
||
signatures: view.signatures,
|
||
savedSignature,
|
||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||
};
|
||
}
|
||
|
||
async generateContract(bookingId: string): Promise<Booking> {
|
||
const booking = await this.requireBooking(bookingId);
|
||
assertBookingStatus(booking, ['APPROVED']);
|
||
|
||
const templateKey = this.templateResolver.resolve(booking);
|
||
const summary = this.buildContractSummary(booking);
|
||
|
||
// 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.
|
||
try {
|
||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
const updated = await this.bookingsRepository.update(bookingId, {
|
||
status: 'CONTRACT_READY',
|
||
contractSummary: summary,
|
||
contractTemplateKey: templateKey,
|
||
contractGeneratedAt: now,
|
||
} as never);
|
||
return updated!;
|
||
}
|
||
|
||
async streamContract(bookingId: string) {
|
||
const booking = await this.requireBooking(bookingId);
|
||
const templateKey =
|
||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||
const record = await this.upsertContractPdf(
|
||
bookingId,
|
||
booking.reference,
|
||
templateKey,
|
||
);
|
||
return this.filesService.streamById(record.id);
|
||
}
|
||
|
||
async signContract(
|
||
bookingId: string,
|
||
dto: SignContractDto,
|
||
options: { signerUserId?: string; ipAddress?: string },
|
||
): Promise<Booking> {
|
||
const booking = await this.requireBooking(bookingId);
|
||
const role = dto.role as ContractSignerRole;
|
||
|
||
if (role === 'CUSTOMER') {
|
||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||
const existing = await this.bookingsRepository.findContractSignature(
|
||
bookingId,
|
||
'CUSTOMER',
|
||
);
|
||
if (existing) {
|
||
throw new BadRequestException('Customer has already signed this contract');
|
||
}
|
||
} else {
|
||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||
const existing = await this.bookingsRepository.findContractSignature(
|
||
bookingId,
|
||
'STAFF',
|
||
);
|
||
if (existing) {
|
||
throw new BadRequestException('Staff has already signed this contract');
|
||
}
|
||
}
|
||
|
||
const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
|
||
const sigFile: Express.Multer.File = {
|
||
fieldname: `signature_${role.toLowerCase()}`,
|
||
originalname: `signature-${role.toLowerCase()}-${booking.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: bookingId,
|
||
resource: 'bookings',
|
||
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
|
||
file: sigFile,
|
||
});
|
||
|
||
const now = new Date();
|
||
await this.bookingsRepository.saveContractSignature({
|
||
bookingId,
|
||
signerRole: role,
|
||
signerUserId: options.signerUserId ?? null,
|
||
signerDisplayName: dto.signerDisplayName,
|
||
signedAt: now,
|
||
signatureFileId: fileRecord.id,
|
||
consentText: dto.consentText ?? null,
|
||
ipAddress: options.ipAddress ?? null,
|
||
});
|
||
|
||
// Persist the just-used signature to the signer's reusable profile so they
|
||
// don't have to redraw it on the next contract. Best-effort: a failure here
|
||
// must never block contract execution.
|
||
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}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
|
||
// Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it
|
||
// does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer
|
||
// to upload clearance documents instead of straight into the batch pipeline.
|
||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||
const clearanceCode = clearanceSettingCode(
|
||
booking.tradeDirection,
|
||
booking.freightType,
|
||
includesCustoms,
|
||
);
|
||
|
||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||
|
||
if (role === 'CUSTOMER') {
|
||
updates.status = 'SIGNED_CUSTOMER';
|
||
updates.customerSignedAt = now;
|
||
} else if (isGeneralContract) {
|
||
// A general contract is NOT paid up front — each drawdown order is priced
|
||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||
// opens its ordering window; orders spawn their own priced child bookings.
|
||
const expiresAt = new Date(now);
|
||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||
updates.fullyExecutedAt = now;
|
||
updates.marketingApprovedAt = now;
|
||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||
updates.lockedAt = now;
|
||
updates.status = 'CONTRACT_ACTIVE';
|
||
updates.expiresAt = expiresAt;
|
||
} else {
|
||
updates.fullyExecutedAt = now;
|
||
updates.marketingApprovedAt = now;
|
||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||
updates.lockedAt = now;
|
||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||
}
|
||
|
||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||
// clearance bookings enter operations after the GL document gate.
|
||
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||
}
|
||
try {
|
||
await this.upsertContractPdf(
|
||
bookingId,
|
||
booking.reference,
|
||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||
);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||
);
|
||
}
|
||
return updated!;
|
||
}
|
||
|
||
async getSignatures(bookingId: string) {
|
||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||
await this.inlineSignatureImages(views);
|
||
return { signatures: views };
|
||
}
|
||
|
||
private async upsertContractPdf(
|
||
bookingId: string,
|
||
reference: string,
|
||
templateKey: string,
|
||
): Promise<FileRecord> {
|
||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||
view.templateKey = templateKey;
|
||
view.template = getTemplateMeta(templateKey);
|
||
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: bookingId,
|
||
resource: 'bookings',
|
||
code: 'contract',
|
||
file,
|
||
});
|
||
}
|
||
|
||
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)));
|
||
});
|
||
}
|
||
|
||
private decodeSignatureImage(base64: string): Buffer {
|
||
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||
return Buffer.from(raw, 'base64');
|
||
}
|
||
|
||
private async requireBooking(id: string): Promise<Booking> {
|
||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||
return booking;
|
||
}
|
||
}
|