mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
310 lines
11 KiB
TypeScript
310 lines
11 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. */
|
|
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();
|
|
|
|
// 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(),
|
|
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: {
|
|
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 }));
|
|
}
|
|
}
|