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

314 lines
11 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 { BookingsRepository } from '../modules/bookings/bookings.repository';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
BookingContractSignature,
ContractSignerRole,
} from '../modules/bookings/entities/booking-contract-signature.entity';
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
role: ContractSignerRole;
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/**
* Round company seal shown beside the signature. Populated for the EDR
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
*/
stampImageUrl?: string | null;
}
/**
* DB-backed contract template (freight.contract_templates) attached to the
* view model when an active template matches the contract's direction/freight
* pair. The renderer turns its articles into numbered clauses and switches to
* the dedicated edr-dynamic.hbs layout; absent, the legacy generic layout with
* code-defined clause packs is used.
*/
export interface ContractDynamicTemplateView {
code: string;
name: string;
documentTitle: string;
whereasClauses: string[];
articles: Array<{ id: string; title: string; body: string; order: number }>;
}
export interface ContractViewModel {
bookingId: string;
reference: string;
status: string;
templateKey: string;
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
/**
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
* from `contractDate`, which is the day the document is generated — these are
* the dates the contract is actually in force between. "—" when unset.
*/
contractStartDate: string;
contractEndDate: string;
client: {
companyName: string;
companyAddress: string;
companyLocation: string;
phone: string;
email: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
businessLicense: string;
};
provider: {
name: string;
address: string;
phone: string;
email: string;
tinNumber: string;
};
schedule: {
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
serviceType: string;
scheduledDate: string;
contractType: string;
cargoDescription: string;
/**
* The named cargo type on its own (e.g. "Coffee"), separate from
* `cargoDescription` which folds in free text and a container fallback.
* Lets a clause name the commodity without the surrounding prose.
*/
cargoTypeName: string;
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
containerType: string;
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
cargoSummary: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
firstMilePickupAddress: string;
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
/**
* The live origin → destination rate schedule (base freight lanes + services
* + surcharges) matching this contract's direction and freight kind. Drives
* the pricing article's rate table so the contract mirrors the rate config.
*/
rateSchedule: RateSchedule;
signatures: ContractSignatureView[];
canSignCustomer: boolean;
canSignStaff: boolean;
hasContractDocument: boolean;
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
dynamicTemplate?: ContractDynamicTemplateView;
}
@Injectable()
export class ContractViewModelBuilder {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const template = getTemplateMeta(templateKey);
const pricing = await this.pricingBuilder.build(booking);
const rateSchedule = await this.rateScheduleBuilder.build(
template.direction,
template.freight,
);
const signatures = await this.loadSignatures(bookingId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
const hasContractFile = Boolean(
booking.files?.some((f) => f.code === 'contract'),
);
const view: ContractViewModel = {
bookingId: booking.id,
reference: booking.reference,
status: booking.status,
templateKey,
template,
contractDate: new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
contractStartDate: this.formatDate(booking.contractValidFrom),
contractEndDate: this.formatDate(booking.contractValidUntil),
client: {
companyName: booking.company?.name ?? 'Client',
companyAddress: this.valueOrDash(booking.company?.address),
companyLocation: this.valueOrDash(booking.company?.country),
phone: this.valueOrDash(booking.company?.phone),
email: this.valueOrDash(booking.company?.email),
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(
booking.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(booking),
pricing,
rateSchedule,
signatures,
// Government contracts are generated at creation and signable at any
// time, in any order — no status gate, no customer-first sequencing.
canSignCustomer: booking.isGovernment
? !hasCustomer
: booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff: booking.isGovernment
? !hasStaff
: booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
};
return { booking, view };
}
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((s) => this.toSignatureView(s));
await this.attachProviderStamp(views);
return views;
}
/**
* Stamp the EDR side of the contract with the ONE global company stamp
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
* stored per signature and the seal is read live at render time. The client
* side is left alone: a customer's own stamp is their business.
*
* Read live and deliberately not snapshotted, so replacing the company stamp
* re-seals contracts on their next render. `getStampImageUrl()` never throws
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
* signature inliner skips.
*/
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
const staff = signatures.filter((s) => s.role === 'STAFF');
if (staff.length === 0) return;
const stampImageUrl = await this.stampSettings.getStampImageUrl();
for (const sig of staff) {
sig.stampImageUrl = stampImageUrl;
}
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {
return {
role: row.signerRole,
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
};
}
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
const cargoName =
booking.freightType === 'BULK'
? booking.cargoFreeText ||
booking.cargoType?.cargoTypeName ||
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
// A booking may carry both sizes; name each one once, in the order booked.
const containerType = [
...new Set(
(booking.bookingContainers ?? [])
.map(
(line) =>
line.containerType?.label ??
(line.containerType?.sizeFt
? `${line.containerType.sizeFt}ft`
: line.containerSize) ??
'',
)
.filter(Boolean),
),
].join(', ');
return {
originLabel: this.yardLabel(booking.originYard),
destinationLabel: this.yardLabel(booking.destinationYard),
tradeDirection: this.valueOrDash(booking.tradeDirection),
freightType: this.valueOrDash(booking.freightType),
serviceType: this.valueOrDash(
booking.serviceType?.serviceName ?? booking.serviceType?.code,
),
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
containerType: this.valueOrDash(containerType),
cargoSummary: this.valueOrDash(
[cargoName, containerType ? `(${containerType})` : null]
.filter(Boolean)
.join(' '),
),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
firstMilePickupAddress: this.valueOrDash(
booking.firstMilePickupAddress,
),
lastMileDeliveryAddress: this.valueOrDash(
booking.lastMileDeliveryAddress,
),
};
}
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);
}
}