import { BadRequestException, ConflictException, 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, CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, UpdateContractTemplateDto, } from "./dto/contract-template.dto"; import { BulkTemplateDirection, bulkTemplateCode, bulkTemplateDirectionFor, 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)); // Seeded container templates first in canonical order, then staff-created // bulk templates alphabetically. return templates.sort((a, b) => { const ra = rank.get(a.code as ContractTemplateCode) ?? 99; const rb = rank.get(b.code as ContractTemplateCode) ?? 99; return ra !== rb ? ra - rb : a.name.localeCompare(b.name); }); } async getByCode(code: string): Promise { const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { throw new NotFoundException(`Contract template ${code} not found`); } return template; } /** * Staff-created bulk template for one (cargo type, direction, customs * option) triple. The cargo type must have hasContractTemplate enabled and * the combination must not already exist — the same commodity + direction + * customs pairing is edited, never duplicated. * * Intercity is domestic and crosses no border, so it carries no customs * variant: the flag must be omitted and is stored as null. */ async create(dto: CreateContractTemplateDto): Promise { const cargoType = await this.repository.findCargoType(dto.cargoTypeId); if (!cargoType) { throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); } if (!cargoType.hasContractTemplate) { throw new BadRequestException( `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, ); } const direction = dto.tradeDirection; const intercity = direction === "INTERCITY"; if (intercity && dto.withCustoms !== undefined) { throw new BadRequestException( "Intercity contracts are domestic and cross no border — they have no customs clearing variant", ); } if (!intercity && dto.withCustoms === undefined) { throw new BadRequestException( `A ${direction.toLowerCase()} template must state whether customs clearing is included`, ); } const withCustoms = intercity ? null : Boolean(dto.withCustoms); const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms); const existing = await this.repository.findByCargoCombo( dto.cargoTypeId, direction, withCustoms, ); if (existing) { throw new ConflictException( `${label} already exists — edit that template instead`, ); } const template = new ContractTemplate(); template.code = bulkTemplateCode(cargoType.code, direction, withCustoms); template.name = dto.name ?? label; template.description = dto.description ?? null; template.documentTitle = withCustoms ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` : `${cargoType.cargoTypeName} Transportation Services`; template.whereasClauses = []; template.articles = []; template.isActive = true; template.cargoTypeId = cargoType.id; template.tradeDirection = direction; template.withCustoms = withCustoms; template.isSystem = false; try { return await this.repository.saveTemplate(template); } catch (error) { // Partial unique index backstop for concurrent creates of the same combo. if ((error as { code?: string })?.code === "23505") { throw new ConflictException( `${label} already exists — edit that template instead`, ); } throw error; } } /** Human label for one bulk combination, used for names and conflict errors. */ private comboLabel( cargoTypeName: string, direction: BulkTemplateDirection, withCustoms: boolean | null, ): string { const dir = direction.charAt(0) + direction.slice(1).toLowerCase(); const customs = withCustoms === null ? "" : withCustoms ? ", with customs clearing" : ", without customs clearing"; return `${cargoTypeName} Bulk Contract (${dir}${customs})`; } /** Bulk templates only — the five seeded container templates are permanent. */ async remove(code: string): Promise { const template = await this.getByCode(code); if (template.isSystem) { throw new BadRequestException( "System container templates cannot be deleted", ); } await this.repository.softDelete(template.id); } /** * The active template used when generating a contract document. Container * contracts resolve through the fixed direction/customs codes; bulk contracts * resolve through the staff-created template for the contract's cargo type * (or its parent group), trade direction and customs option. A domestic * contract resolves to the intercity template regardless of its customs flag. * Null when nothing matches or the match is deactivated (the renderer then * falls back to the built-in generic layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, cargoTypeId?: string | null, ): Promise { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { if (!cargoTypeId) return null; const direction = bulkTemplateDirectionFor(tradeDirection); return this.repository.findActiveBulkTemplate( cargoTypeId, direction, direction === "INTERCITY" ? null : Boolean(customsClearingEnabled), ); } 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, dynamicTemplate); return { html: this.renderer.render(view) }; } /** * Registry key the mock preview renders against. Staff-created bulk * templates aren't in the fixed code map — they preview against the bulk * pack matching their own direction and customs option. */ private previewKeyFor(template: ContractTemplate): string { if (template.cargoTypeId) { const direction = bulkTemplateDirectionFor(template.tradeDirection); const dir = direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM"; const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY"; return `${dir}_BULK_USD_${scope}`; } return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; } /** * Preview direction: bulk templates carry it on the row, the fixed container * codes carry it as the code prefix. */ private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection { if (template.cargoTypeId) { return bulkTemplateDirectionFor(template.tradeDirection); } return bulkTemplateDirectionFor(template.code.split("_")[0]); } private buildMockView( template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { const code = template.code; const previewKey = this.previewKeyFor(template); const meta = getTemplateMeta(previewKey); const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const direction = this.previewDirectionFor(template); 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(direction, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", templateKey: previewKey, 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: direction === "INTERCITY" ? "DOMESTIC" : direction, 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( direction: BulkTemplateDirection, isBulk: boolean, ): RateSchedule { const lane = direction === "EXPORT" ? "Galaan Multipurpose Port → SGTD" : direction === "INTERCITY" ? "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 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 })); } }