Files
edr-platform/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
Marshal 8cf49aa1cc enhance contract document rendering with detailed cargo information
- 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.
2026-08-04 13:06:03 +00:00

332 lines
12 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 { 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<ContractTemplateCode, string> = {
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<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/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<ContractTemplate | null> {
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<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();
// 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 }));
}
}