mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
506 lines
19 KiB
TypeScript
506 lines
19 KiB
TypeScript
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<ContractTemplateCode, string> = {
|
||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||
EXPORT_BULK_ETHIOPIAN_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_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||
// Carriage of the equipment itself — no cargo, no clearing, so it previews
|
||
// against the transport-only scope like every other non-customs code.
|
||
IMPORT_EMPTY_CONTAINER: "IMP_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));
|
||
// 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<ContractTemplate> {
|
||
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<ContractTemplate> {
|
||
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 || dto.ethiopianCustomsOnly)) {
|
||
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 ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
|
||
if (ethiopianOnly && !withCustoms) {
|
||
throw new BadRequestException(
|
||
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
|
||
);
|
||
}
|
||
|
||
const label = this.comboLabel(
|
||
cargoType.cargoTypeName,
|
||
direction,
|
||
withCustoms,
|
||
ethiopianOnly,
|
||
);
|
||
const existing = await this.repository.findByCargoCombo(
|
||
dto.cargoTypeId,
|
||
direction,
|
||
withCustoms,
|
||
ethiopianOnly,
|
||
);
|
||
if (existing) {
|
||
throw new ConflictException(
|
||
`${label} already exists — edit that template instead`,
|
||
);
|
||
}
|
||
|
||
const template = new ContractTemplate();
|
||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
|
||
template.name = dto.name ?? label;
|
||
template.description = dto.description ?? null;
|
||
template.documentTitle = withCustoms
|
||
? ethiopianOnly
|
||
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
|
||
: `${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.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
|
||
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,
|
||
ethiopianCustomsOnly = false,
|
||
): string {
|
||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||
const customs =
|
||
withCustoms === null
|
||
? ""
|
||
: withCustoms
|
||
? ethiopianCustomsOnly
|
||
? ", with Ethiopian customs clearing only"
|
||
: ", with customs clearing"
|
||
: ", without customs clearing";
|
||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||
}
|
||
|
||
/** Bulk templates only — the seeded container templates are permanent. */
|
||
async remove(code: string): Promise<void> {
|
||
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,
|
||
ethiopianCustomsOnly?: boolean | null,
|
||
cargoCondition?: string | null,
|
||
): Promise<ContractTemplate | null> {
|
||
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),
|
||
direction === "INTERCITY"
|
||
? false
|
||
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
|
||
);
|
||
}
|
||
const code = contractTemplateCodeFor(
|
||
tradeDirection,
|
||
freightType,
|
||
customsClearingEnabled,
|
||
ethiopianCustomsOnly,
|
||
cargoCondition,
|
||
);
|
||
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, 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 }));
|
||
}
|
||
}
|