mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
- 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.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
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 }));
|
||
}
|
||
}
|