Files
edr-platform/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts

401 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };