import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { RateSchedule } from "../../contracts/contract-rate-schedule.builder"; 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. * The registry's FORWARDING scope carries the customs/clearing clause pack, so * the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against * TRANSPORT_ONLY. */ const PREVIEW_TEMPLATE_KEYS: Record = { IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING", IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY", EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING", EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY", INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING", IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY", EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING", EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", }; @Injectable() export class ContractTemplatesService { constructor( private readonly repository: ContractTemplatesRepository, private readonly renderer: ContractRendererService, ) {} async list(): Promise { 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 { 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/customs triple; null when missing or deactivated (the * renderer then falls back to the built-in generic layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, ): Promise { const code = contractTemplateCodeFor( tradeDirection, freightType, customsClearingEnabled, ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; } async update(code: string, dto: UpdateContractTemplateDto): Promise { 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 { 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 { 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 { 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 { 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(); // Representative rate schedule so the admin preview shows the live-rate // table shape. Real contracts populate this from freight.rates (LIVE). const rateSchedule = this.mockRateSchedule(code, isBulk); 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(), // Representative validity window for the admin preview only. contractStartDate: `1 January ${now.getFullYear()}`, contractEndDate: `31 December ${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", cargoTypeName: isBulk ? "Steel billets" : "Coffee", containerType: isBulk ? "—" : "40ft", cargoSummary: isBulk ? "Steel billets × 2,800" : "Coffee (40ft) × 12; Sesame (20ft) × 6", totalWeightVgm: "—", equipmentReturn: isBulk ? "—" : "With empty return", hazardousLabel: "No", firstMilePickupAddress: "—", lastMileDeliveryAddress: "—", }, pricing: { lineItems: [], surcharges: [], totalAmount: 0, currency: "USD", equipmentReturn: isBulk ? "—" : "With empty return", originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", containerLines: [], } as unknown as ContractViewModel["pricing"], rateSchedule, signatures: [], canSignCustomer: false, canSignStaff: false, hasContractDocument: false, hasCustomerSignature: false, hasStaffSignature: false, dynamicTemplate, }; } /** Static, representative rate schedule for the admin preview only. */ private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { const dir = code.startsWith("IMPORT") ? "import" : code.startsWith("EXPORT") ? "export" : "domestic"; const lane = dir === "export" ? "Galaan Multipurpose Port → SGTD" : dir === "domestic" ? "Mojo Dry Port → Dire Dawa" : "Negad → Mojo Dry Port"; const freightLanes = isBulk ? [ { route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" }, ] : [ { route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" }, { route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" }, ]; return { freightLanes, additionalServices: [ { route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, { route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, ], surcharges: [ { route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" }, ], isEmpty: false, currencyLabel: "USD", }; } 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 })); } }