mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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 };
|
||||
@@ -25,6 +25,26 @@
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if pricing.unitRates}}
|
||||
<h3>Unit Rate Schedule</h3>
|
||||
<p>
|
||||
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
|
||||
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
|
||||
</p>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Unit price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.unitRates}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
@@ -56,6 +76,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
<h3>Terms of payment</h3>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
|
||||
Reference in New Issue
Block a user