import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** * The seeded container templates (import/export split by customs-clearing * option — full, Ethiopian-only, none; intercity is domestic, crosses no * border, so it has a single template). These are system rows: always * present, never deletable. * * Bulk templates are NOT seeded — staff create them per bulk cargo type * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option * (`withCustoms`), one template per combination. Their codes are generated by * `bulkTemplateCode` below. The retired direction-keyed bulk codes remain * listed so old frozen document snapshots still label correctly. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed * documents. * * The `_CUSTOMS` variant is issued when the contract has customs clearing * enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's * behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only * (the Service Provider clears the Ethiopian side only, Djibouti stays with the * Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles * its own declarations. */ export const CONTRACT_TEMPLATE_CODES = [ "IMPORT_BULK_CUSTOMS", "IMPORT_BULK_ETHIOPIAN_CUSTOMS", "IMPORT_BULK_NO_CUSTOMS", "EXPORT_BULK_CUSTOMS", "EXPORT_BULK_ETHIOPIAN_CUSTOMS", "EXPORT_BULK_NO_CUSTOMS", "INTERCITY_BULK", "IMPORT_CONTAINER_CUSTOMS", "IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "IMPORT_CONTAINER_NO_CUSTOMS", "EXPORT_CONTAINER_CUSTOMS", "EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", ] as const; export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number]; /** * One dynamic article on a contract template. `body` is plain multiline text: * each non-empty line renders as a numbered clause; lines prefixed with "- " * render as bullet points nested under the preceding clause. A single-line * body renders as an unnumbered paragraph. Handlebars placeholders (e.g. * {{client.companyName}}, {{contractDate}}, {{contractYear}}, {{reference}}) * are interpolated against the contract view model at render time. */ export interface ContractTemplateArticle { id: string; title: string; body: string; order: number; } /** * Map a contract's stored direction/freight/customs triple onto a template * code. `customsClearingEnabled` is treated as false when absent so an older * contract row with a null flag still resolves to a real template rather than * falling through to the generic layout. * * Intercity is domestic and has no customs leg, so it resolves to a single * unsuffixed code regardless of the flag. */ export function contractTemplateCodeFor( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, ethiopianCustomsOnly?: boolean | null, ): ContractTemplateCode { const direction = tradeDirection === "IMPORT" ? "IMPORT" : tradeDirection === "EXPORT" ? "EXPORT" : "INTERCITY"; const freight = (freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER"; if (direction === "INTERCITY") { return `INTERCITY_${freight}` as ContractTemplateCode; } const customs = customsClearingEnabled ? ethiopianCustomsOnly ? "ETHIOPIAN_CUSTOMS" : "CUSTOMS" : "NO_CUSTOMS"; return `${direction}_${freight}_${customs}` as ContractTemplateCode; } /** The three directions a bulk template can be written for. */ export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const; export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number]; /** * Contracts store DOMESTIC for intercity movements; templates use INTERCITY. * Anything that is not an explicit IMPORT/EXPORT is domestic, matching * `contractTemplateCodeFor`. */ export function bulkTemplateDirectionFor( tradeDirection?: string | null, ): BulkTemplateDirection { const value = (tradeDirection ?? "").toUpperCase(); return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY"; } /** * Generated code for a staff-created bulk template. Intercity gets no customs * suffix — it crosses no border, so the variant does not exist. */ export function bulkTemplateCode( cargoCode: string, direction: BulkTemplateDirection, withCustoms: boolean | null, ethiopianCustomsOnly = false, ): string { const suffix = direction === "INTERCITY" ? "" : withCustoms ? ethiopianCustomsOnly ? "_ETHIOPIAN_CUSTOMS" : "_CUSTOMS" : "_NO_CUSTOMS"; return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase(); } @Entity({ schema: "freight", name: "contract_templates" }) // Uniqueness lives in partial DB indexes (live rows only): code, and // (cargo_type_id, trade_direction, coalesce(with_customs,false)) for // staff-created bulk templates. @Index(["code"]) export class ContractTemplate extends BaseEntity { @Column({ name: "code", type: "varchar", length: 80 }) code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @Column({ name: "description", type: "text", nullable: true }) description?: string | null; /** Cover-page service line, e.g. "Steel Billet Transportation and Customs Clearance Services". */ @Column({ name: "document_title", type: "varchar", length: 300 }) documentTitle!: string; /** WHEREAS recitals rendered between the parties block and the articles. */ @Column({ name: "whereas_clauses", type: "jsonb", default: () => "'[]'" }) whereasClauses!: string[]; @Column({ name: "articles", type: "jsonb", default: () => "'[]'" }) articles!: ContractTemplateArticle[]; @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; /** Bulk templates only: the cargo type this template is written for. */ @Column({ name: "cargo_type_id", type: "uuid", nullable: true }) cargoTypeId?: string | null; @ManyToOne(() => CargoType, { nullable: true }) @JoinColumn({ name: "cargo_type_id" }) cargoType?: CargoType | null; /** Bulk templates only: IMPORT, EXPORT or INTERCITY. */ @Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true }) tradeDirection?: BulkTemplateDirection | null; /** * Bulk templates only: whether this is the with-customs-clearing variant. * Always null for INTERCITY templates — domestic movements have no customs * leg, so neither variant applies. */ @Column({ name: "with_customs", type: "boolean", nullable: true }) withCustoms?: boolean | null; /** * Bulk templates only: the with-customs variant restricted to Ethiopian-side * clearing (Djibouti stays with the Client). Only meaningful when * `withCustoms` is true; null/false otherwise. */ @Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true }) ethiopianCustomsOnly?: boolean | null; /** The seeded container templates — cannot be deleted. */ @Column({ name: "is_system", type: "boolean", default: false }) isSystem!: boolean; }