mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { hazardClassLabel } from '@edr/types';
|
||
|
||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||
import {
|
||
Contract,
|
||
ContractDocumentSnapshot,
|
||
} from '../modules/contracts/entities/contract.entity';
|
||
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
|
||
import {
|
||
ContractSignature,
|
||
ContractSignerRole,
|
||
} from '../modules/contracts/entities/contract-signature.entity';
|
||
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
|
||
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
|
||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||
import { getTemplateMeta } from './contract-template.registry';
|
||
import {
|
||
ContractDynamicTemplateView,
|
||
ContractViewModel,
|
||
} from './contract-view-model.builder';
|
||
import { RateSchedule } from './contract-rate-schedule.builder';
|
||
|
||
/**
|
||
* Signature row for the contract PDF. Mirrors the booking builder's
|
||
* `ContractSignatureView` but widens `role` to the contract's signer roles
|
||
* (CUSTOMER | STAFF | DIRECTOR | CEO).
|
||
*/
|
||
export interface ContractDocumentSignatureView {
|
||
role: ContractSignerRole;
|
||
signerDisplayName: string;
|
||
signedAt: string;
|
||
signatureImageUrl?: string | null;
|
||
/** Company stamp/seal; rendered next to the signature when present. */
|
||
stampImageUrl?: string | null;
|
||
}
|
||
|
||
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||
export interface ContractUnitRateRow {
|
||
label: string;
|
||
unitPrice: number;
|
||
unit: string;
|
||
currency: string;
|
||
}
|
||
|
||
/**
|
||
* Pricing schedule for a Contract document: a unit-rate schedule (one price per
|
||
* unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
|
||
* to stay structurally compatible with the renderer's expectations of
|
||
* {@link ContractViewModel.pricing} (it reads `currency`).
|
||
*/
|
||
export interface ContractUnitRateSchedule {
|
||
displayMode: 'UNIT_RATES';
|
||
unitRates: ContractUnitRateRow[];
|
||
currency: string;
|
||
equipmentReturn?: string;
|
||
originLabel: string;
|
||
destinationLabel: string;
|
||
}
|
||
|
||
/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */
|
||
function unitLabel(unit: string): string {
|
||
switch (unit) {
|
||
case 'per_container':
|
||
return 'container';
|
||
case 'per_ton':
|
||
return 'ton';
|
||
case 'per_item':
|
||
return 'item';
|
||
case 'per_km':
|
||
return 'km';
|
||
default:
|
||
return 'unit';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Builds the contract PDF view-model from the {@link Contract} aggregate (the new
|
||
* source of truth) — mirrors {@link ContractViewModelBuilder} but every field is
|
||
* sourced from the contract, its routes, cargo scope and unit-rate breakdown.
|
||
* The legacy booking-based builder remains untouched for the migration window.
|
||
*/
|
||
@Injectable()
|
||
export class ContractDocumentViewModelBuilder {
|
||
constructor(
|
||
private readonly contractsRepository: ContractsRepository,
|
||
private readonly templateResolver: ContractTemplateResolver,
|
||
private readonly contractTemplates: ContractTemplatesService,
|
||
) {}
|
||
|
||
async build(
|
||
contractId: string,
|
||
): Promise<{ contract: Contract; view: ContractViewModel }> {
|
||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||
if (!contract) {
|
||
throw new NotFoundException(`Contract ${contractId} not found`);
|
||
}
|
||
|
||
const templateKey =
|
||
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
|
||
let template = getTemplateMeta(templateKey);
|
||
|
||
// The document articles come, in order of preference, from:
|
||
// 1. this contract's frozen snapshot (staff accepted / edited it) — the
|
||
// shared six templates are never consulted for these contracts;
|
||
// 2. the admin-editable DB template matching the direction/freight pair;
|
||
// 3. the code-defined generic layout (handled below when none of the above).
|
||
const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
|
||
let dynamicTemplate: ContractDynamicTemplateView | undefined;
|
||
if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
|
||
dynamicTemplate = {
|
||
code: snapshot.code ?? 'CONTRACT',
|
||
name: snapshot.name ?? template.title,
|
||
documentTitle: snapshot.documentTitle ?? '',
|
||
whereasClauses: snapshot.whereasClauses ?? [],
|
||
articles: snapshot.articles,
|
||
};
|
||
} else {
|
||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||
contract.tradeDirection,
|
||
contract.freightType,
|
||
contract.customsClearingEnabled,
|
||
// Bulk templates are keyed by the contract's cargo type.
|
||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||
);
|
||
dynamicTemplate = dynamicSource
|
||
? {
|
||
code: dynamicSource.code,
|
||
name: dynamicSource.name,
|
||
documentTitle: dynamicSource.documentTitle,
|
||
whereasClauses: dynamicSource.whereasClauses ?? [],
|
||
articles: dynamicSource.articles ?? [],
|
||
}
|
||
: undefined;
|
||
}
|
||
if (dynamicTemplate) {
|
||
template = {
|
||
...template,
|
||
title: dynamicTemplate.name,
|
||
templateFile: 'edr-dynamic.hbs',
|
||
};
|
||
}
|
||
|
||
const pricing = this.buildPricing(contract);
|
||
const rateSchedule = this.buildRateSchedule(pricing);
|
||
const signatures = await this.loadSignatures(contractId);
|
||
|
||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||
// Signed before company stamps were required — the customer has to sign
|
||
// again to attach one, otherwise EDR can never counter-sign the contract.
|
||
const customerStampMissing = signatures.some(
|
||
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
|
||
);
|
||
const hasContractFile = Boolean(
|
||
contract.files?.some((f) => f.code === 'contract'),
|
||
);
|
||
|
||
const view: ContractViewModel = {
|
||
bookingId: contract.id,
|
||
reference: contract.reference,
|
||
status: contract.status,
|
||
templateKey,
|
||
template,
|
||
contractDate: new Date().toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
}),
|
||
contractYear: new Date().getFullYear(),
|
||
contractStartDate: this.formatDate(contract.contractValidFrom),
|
||
contractEndDate: this.formatDate(contract.contractValidUntil),
|
||
client: {
|
||
companyName: contract.company?.name ?? 'Client',
|
||
companyAddress: this.valueOrDash(contract.company?.address),
|
||
companyLocation: this.valueOrDash(contract.company?.country),
|
||
phone: this.valueOrDash(contract.company?.phone),
|
||
email: this.valueOrDash(contract.company?.email),
|
||
tinNumber: this.valueOrDash(contract.company?.tin),
|
||
vatNumber: this.valueOrDash(contract.company?.vatNumber),
|
||
fanNumber: this.valueOrDash(contract.company?.fanNumber),
|
||
businessLicense: this.valueOrDash(
|
||
contract.company?.companyProfiles?.[0]?.businessLicense,
|
||
),
|
||
},
|
||
provider: {
|
||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||
address: 'Addis Ababa, Ethiopia',
|
||
phone: '+251 11 872 0000',
|
||
email: 'info@edr.gov.et',
|
||
tinNumber: '—',
|
||
},
|
||
schedule: this.buildSchedule(contract),
|
||
pricing: pricing as unknown as ContractViewModel['pricing'],
|
||
rateSchedule,
|
||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||
// view-model's narrower CUSTOMER|STAFF role union.
|
||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||
canSignCustomer:
|
||
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
|
||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
|
||
canSignStaff:
|
||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||
hasContractDocument: hasContractFile,
|
||
hasCustomerSignature: hasCustomer,
|
||
hasStaffSignature: hasStaff,
|
||
dynamicTemplate,
|
||
};
|
||
|
||
return { contract, view };
|
||
}
|
||
|
||
private async loadSignatures(
|
||
contractId: string,
|
||
): Promise<ContractDocumentSignatureView[]> {
|
||
const rows = await this.contractsRepository.findSignatures(contractId);
|
||
return rows.map((s) => this.toSignatureView(s));
|
||
}
|
||
|
||
toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
|
||
return {
|
||
role: row.role,
|
||
signerDisplayName: row.signerDisplayName,
|
||
signedAt: this.formatDate(row.signedAt),
|
||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||
stampImageUrl: row.stampFile?.url ?? null,
|
||
};
|
||
}
|
||
|
||
/** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */
|
||
private buildPricing(contract: Contract): ContractUnitRateSchedule {
|
||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||
const currency = breakdown?.currency ?? contract.paymentCurrency;
|
||
const lineItems = breakdown?.lineItems ?? [];
|
||
const firstRoute = this.firstRoute(contract);
|
||
|
||
return {
|
||
displayMode: 'UNIT_RATES',
|
||
unitRates: lineItems.map((line) => ({
|
||
label: line.label,
|
||
unitPrice: line.unitPrice,
|
||
unit: unitLabel(line.unit),
|
||
currency,
|
||
})),
|
||
currency,
|
||
equipmentReturn: contract.equipmentReturn ?? '—',
|
||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A rate schedule for the contract PDF, sourced from the contract's own frozen
|
||
* unit rates (its agreed lane prices) rather than the global rate config — a
|
||
* signed contract must show the prices it was signed on. Rendered as freight
|
||
* lanes labelled with the contract's primary origin → destination route.
|
||
*/
|
||
private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule {
|
||
const route = `${pricing.originLabel} → ${pricing.destinationLabel}`;
|
||
const freightLanes = pricing.unitRates.map((line) => ({
|
||
route,
|
||
cargo: line.label,
|
||
currency: line.currency,
|
||
amount: this.formatAmount(line.unitPrice),
|
||
unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`,
|
||
}));
|
||
|
||
return {
|
||
freightLanes,
|
||
additionalServices: [],
|
||
surcharges: [],
|
||
isEmpty: freightLanes.length === 0,
|
||
currencyLabel: pricing.currency,
|
||
};
|
||
}
|
||
|
||
private formatAmount(value: number | string): string {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return String(value);
|
||
return num.toLocaleString('en-US', {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 2,
|
||
});
|
||
}
|
||
|
||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||
const firstRoute = this.firstRoute(contract);
|
||
const scope = contract.cargoScope ?? [];
|
||
const cargoScope = scope[0];
|
||
const cargoName =
|
||
cargoScope?.cargoType?.cargoTypeName ||
|
||
cargoScope?.cargoFreeText ||
|
||
(cargoScope?.containerSize
|
||
? `${cargoScope.containerSize} container`
|
||
: 'Container cargo');
|
||
|
||
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
|
||
// 40ft); name each distinctly rather than collapsing to the first.
|
||
const containerType = [
|
||
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
|
||
].join(', ');
|
||
const cargoTypeName = [
|
||
...new Set(
|
||
scope
|
||
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
|
||
.filter(Boolean),
|
||
),
|
||
].join(', ');
|
||
const cargoSummary = scope
|
||
.map((s) => {
|
||
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
|
||
const size = s.containerSize ? `(${s.containerSize})` : null;
|
||
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
|
||
return [name, size, cap].filter(Boolean).join(' ');
|
||
})
|
||
.filter(Boolean)
|
||
.join('; ');
|
||
|
||
return {
|
||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||
tradeDirection: this.valueOrDash(contract.tradeDirection),
|
||
freightType: this.valueOrDash(contract.freightType),
|
||
serviceType: this.valueOrDash(
|
||
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||
),
|
||
// Estimated shipment date was removed from the contract wizard; the
|
||
// binding scheduled date is set per-booking, not on the contract.
|
||
scheduledDate: this.formatDate(null),
|
||
contractType: this.valueOrDash(contract.contractType),
|
||
cargoDescription: this.valueOrDash(cargoName),
|
||
cargoTypeName: this.valueOrDash(cargoTypeName),
|
||
containerType: this.valueOrDash(containerType),
|
||
cargoSummary: this.valueOrDash(cargoSummary),
|
||
totalWeightVgm: '—',
|
||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||
// A hazardous contract names the declared class + UN number on the
|
||
// schedule — the flag alone is not a dangerous-goods declaration.
|
||
hazardousLabel: contract.isHazardous
|
||
? [
|
||
hazardClassLabel(contract.hazardClass) ?? 'Yes',
|
||
contract.unNumber ? `UN ${contract.unNumber}` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' · ')
|
||
: 'No',
|
||
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||
};
|
||
}
|
||
|
||
/** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
|
||
private firstRoute(contract: Contract): ContractRoute | undefined {
|
||
const routes = [...(contract.routes ?? [])].sort(
|
||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||
);
|
||
return routes[0];
|
||
}
|
||
|
||
/**
|
||
* The template resolver reads a Booking; a contract carries equivalent fields
|
||
* under a different shape (cargoType lives on cargoScope). Build a minimal,
|
||
* structurally-compatible adapter rather than widening the resolver signature.
|
||
*/
|
||
private toResolverInput(
|
||
contract: Contract,
|
||
): Parameters<ContractTemplateResolver['resolve']>[0] {
|
||
const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
|
||
return {
|
||
tradeDirection: contract.tradeDirection,
|
||
freightType: contract.freightType,
|
||
paymentCurrency: contract.paymentCurrency,
|
||
cargoType: cargoType ?? undefined,
|
||
serviceType: contract.serviceType,
|
||
} as Parameters<ContractTemplateResolver['resolve']>[0];
|
||
}
|
||
|
||
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||
return this.valueOrDash(yard?.label ?? yard?.code);
|
||
}
|
||
|
||
private formatDate(value?: Date | string | null): string {
|
||
if (!value) return '—';
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '—';
|
||
return date.toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
});
|
||
}
|
||
|
||
private valueOrDash(value?: string | number | null): string {
|
||
if (value === undefined || value === null || value === '') return '—';
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
// Re-export for callers that want the role union without importing the entity.
|
||
export type { ContractSignerRole };
|