mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Updated ContractDocumentViewModelBuilder to include cargoTypeName, containerType, and cargoSummary in the schedule. - Modified contract dynamic template tests to validate the new cargo fields. - Enhanced contract renderer service tests to reflect changes in cargo data structure. - Updated contract view model interface to include new cargo-related fields. - Improved dynamic template rendering to display cargo type and container type. - Refactored exchange settings controller and service to streamline error handling and feed status management. - Introduced article HTML conversion functions to support Quill editor integration for structured article editing. - Added tests for article HTML conversion to ensure correct round-trip processing of clauses and bullets.
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
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 { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||
|
||
export interface ContractSignatureView {
|
||
role: ContractSignerRole;
|
||
signerDisplayName: string;
|
||
signedAt: string;
|
||
signatureImageUrl?: 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,
|
||
) {}
|
||
|
||
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);
|
||
return rows.map((s) => this.toSignatureView(s));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|