mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
263 lines
9.7 KiB
TypeScript
263 lines
9.7 KiB
TypeScript
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 { 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,
|
|
) {}
|
|
|
|
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));
|
|
const template = getTemplateMeta(templateKey);
|
|
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,
|
|
};
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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,
|
|
),
|
|
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
|
|
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 };
|