Files
edr-platform/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
2026-07-09 21:13:07 +00:00

277 lines
10 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 { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { getTemplateMeta } from "../../contracts/contract-template.registry";
import {
ContractDynamicTemplateView,
ContractViewModel,
} from "../../contracts/contract-view-model.builder";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
CreateArticleDto,
PreviewContractTemplateDto,
ReplaceArticleDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
import {
CONTRACT_TEMPLATE_CODES,
ContractTemplate,
ContractTemplateArticle,
ContractTemplateCode,
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
/** Registry keys used to derive labels for the mock preview per template code. */
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@Injectable()
export class ContractTemplatesService {
constructor(
private readonly repository: ContractTemplatesRepository,
private readonly renderer: ContractRendererService,
) {}
async list(): Promise<ContractTemplate[]> {
const templates = await this.repository.findAll();
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
return templates.sort(
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
);
}
async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code));
if (!template) {
throw new NotFoundException(`Contract template ${code} not found`);
}
return template;
}
/**
* The active template used when generating a contract document for the given
* direction/freight pair; null when missing or deactivated (the renderer then
* falls back to the built-in generic layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
freightType?: string | null,
): Promise<ContractTemplate | null> {
const code = contractTemplateCodeFor(tradeDirection, freightType);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;
}
async update(code: string, dto: UpdateContractTemplateDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
if (dto.name !== undefined) template.name = dto.name;
if (dto.description !== undefined) template.description = dto.description;
if (dto.documentTitle !== undefined) template.documentTitle = dto.documentTitle;
if (dto.whereasClauses !== undefined) template.whereasClauses = dto.whereasClauses;
if (dto.isActive !== undefined) template.isActive = dto.isActive;
return this.repository.saveTemplate(template);
}
async addArticle(code: string, dto: CreateArticleDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const articles = this.sorted(template.articles);
const article: ContractTemplateArticle = {
id: randomUUID(),
title: dto.title,
body: dto.body,
order: 0,
};
const index =
dto.position && dto.position <= articles.length ? dto.position - 1 : articles.length;
articles.splice(index, 0, article);
template.articles = this.renumber(articles);
return this.repository.saveTemplate(template);
}
async updateArticle(
code: string,
articleId: string,
dto: UpdateArticleDto,
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const article = template.articles.find((item) => item.id === articleId);
if (!article) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
if (dto.title !== undefined) article.title = dto.title;
if (dto.body !== undefined) article.body = dto.body;
template.articles = this.renumber(this.sorted(template.articles));
return this.repository.saveTemplate(template);
}
async removeArticle(code: string, articleId: string): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const remaining = template.articles.filter((item) => item.id !== articleId);
if (remaining.length === template.articles.length) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
template.articles = this.renumber(this.sorted(remaining));
return this.repository.saveTemplate(template);
}
/** Replace the full ordered article list (also how the editor reorders). */
async replaceArticles(
code: string,
articles: ReplaceArticleDto[],
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
template.articles = this.renumber(
articles.map((item) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: 0,
})),
);
return this.repository.saveTemplate(template);
}
/**
* Render the template against a representative mock contract so admins can
* see the final document without touching a real contract. Draft overrides
* allow previewing unsaved editor state.
*/
async preview(
code: string,
overrides?: PreviewContractTemplateDto,
): Promise<{ html: string }> {
const template = await this.getByCode(code);
const dynamicTemplate: ContractDynamicTemplateView = {
code: template.code,
name: overrides?.name ?? template.name,
documentTitle: overrides?.documentTitle ?? template.documentTitle,
whereasClauses: overrides?.whereasClauses ?? template.whereasClauses,
articles: overrides?.articles
? overrides.articles.map((item, index) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: index + 1,
}))
: this.sorted(template.articles),
};
const view = this.buildMockView(template.code, dynamicTemplate);
return { html: this.renderer.render(view) };
}
private buildMockView(
code: ContractTemplateCode,
dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
const isBulk = code.endsWith("_BULK");
const now = new Date();
const unitRates = isBulk
? [
{ label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" },
{ label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" },
{ label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" },
]
: [
{ label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" },
{ label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" },
{ label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" },
];
return {
bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code],
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
contractYear: now.getFullYear(),
client: {
companyName: "Abyssinia Trading PLC",
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
companyLocation: "Ethiopia",
phone: "+251 91 123 4567",
email: "logistics@abyssiniatrading.et",
tinNumber: "0011223344",
vatNumber: "VAT-556677",
fanNumber: "FAN-889900",
businessLicense: "BL/AA/12/345678",
},
provider: {
name: "Ethio-Djibouti Standard Gauge Railway Share Company",
address: "Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia",
phone: "+251 11 872 0000",
email: "info@edr.gov.et",
tinNumber: "—",
},
schedule: {
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
tradeDirection: code.startsWith("IMPORT")
? "IMPORT"
: code.startsWith("EXPORT")
? "EXPORT"
: "DOMESTIC",
freightType: isBulk ? "BULK" : "CONTAINER",
serviceType: "Rail transport and customs clearance",
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",
firstMilePickupAddress: "—",
lastMileDeliveryAddress: "—",
},
pricing: {
displayMode: "UNIT_RATES",
unitRates,
currency: "USD",
equipmentReturn: isBulk ? "—" : "With empty return",
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
} as unknown as ContractViewModel["pricing"],
signatures: [],
canSignCustomer: false,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
dynamicTemplate,
};
}
private assertCode(code: string): ContractTemplateCode {
const upper = code?.toUpperCase() as ContractTemplateCode;
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
throw new BadRequestException(
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
);
}
return upper;
}
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
private renumber(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return articles.map((article, index) => ({ ...article, order: index + 1 }));
}
}