import { Injectable, NotFoundException } from '@nestjs/common'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; import { Contract } 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 { ContractViewModel } from './contract-view-model.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); // Prefer the admin-editable DB template matching the contract's // direction/freight pair; fall back to the code-defined generic layout // when none is active. const dynamicSource = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, ); const 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 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'], // 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 { 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), }; } 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[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[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 };