Files
edr-platform/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts
Marshal 4efd65c105 feat: key bulk templates by trade direction
Bulk contract templates are now unique per (cargo type, trade direction,
customs option) instead of (cargo type, customs option). Intercity is
domestic and crosses no border, so it carries no customs variant:
with_customs stays null there, enforced by a check constraint.

Contract resolution already passed the trade direction through but the
bulk lookup dropped it, so one template served all three directions.

Container templates are unchanged — cargo_type_id is null on those rows,
which keeps them out of both the new index and the constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:20:28 +00:00

474 lines
18 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,
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_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));
// 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) {
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<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,
): 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),
);
}
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, 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 }));
}
}