Files
edr-platform/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
Marshal 3a1a08b1e1 add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
2026-07-17 23:25:53 +00:00

350 lines
13 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
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;
}
/** 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,
);
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');
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(),
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,
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,
};
}
/** 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 cargoScope = (contract.cargoScope ?? [])[0];
const cargoName =
cargoScope?.cargoType?.cargoTypeName ||
cargoScope?.cargoFreeText ||
(cargoScope?.containerSize
? `${cargoScope.containerSize} container`
: 'Container cargo');
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),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
hazardousLabel: contract.isHazardous ? 'Yes' : '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 };