add custom contrat templates

This commit is contained in:
Marshal
2026-08-04 09:48:49 +00:00
parent aac1175964
commit 7c78a815eb
10 changed files with 366 additions and 58 deletions

View File

@@ -0,0 +1,66 @@
import {
CONTRACT_TEMPLATE_CODES,
contractTemplateCodeFor,
} from './entities/contract-template.entity';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults';
describe('contractTemplateCodeFor', () => {
it('splits import and export by the customs flag', () => {
expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS');
expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS');
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe(
'EXPORT_CONTAINER_CUSTOMS',
);
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe(
'EXPORT_CONTAINER_NO_CUSTOMS',
);
});
it('never gives intercity a customs variant — it crosses no border', () => {
for (const flag of [true, false, null, undefined]) {
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe(
'INTERCITY_CONTAINER',
);
}
});
it('treats a missing customs flag as no customs on cross-border contracts', () => {
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
});
it('only ever resolves to a code that exists', () => {
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
}
}
}
});
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the ten declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(10);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});
it('gives every _CUSTOMS template the customs articles and no other one', () => {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing');
// Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly.
const isCustomsVariant =
seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS');
expect(hasCustomsArticle).toBe(isCustomsVariant);
}
});
});

View File

@@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate {
}
describe("contractTemplateCodeFor", () => {
it("maps every direction/freight pair to one of the six codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
it("maps every direction/freight/customs triple to one of the ten codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS");
expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe(
"IMPORT_BULK_NO_CUSTOMS",
);
expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe(
"EXPORT_CONTAINER_CUSTOMS",
);
// Intercity is domestic — no border, so no customs variant either way.
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe(
"INTERCITY_CONTAINER",
);
expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK");
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
});
});
@@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => {
);
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
const { html } = await service.preview("IMPORT_BULK");
const { html } = await service.preview("IMPORT_BULK_CUSTOMS");
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
});
});

View File

@@ -24,13 +24,22 @@ import {
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
/** Registry keys used to derive labels for the mock preview per template code. */
/**
* 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: "IMP_BULK_USD_FORWARDING",
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
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: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
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",
};
@@ -59,14 +68,19 @@ export class ContractTemplatesService {
/**
* 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).
* 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);
const code = contractTemplateCodeFor(
tradeDirection,
freightType,
customsClearingEnabled,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;
}

View File

@@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* The six canonical contract document templates, one per
* (trade direction × freight type) combination. Contracts store DOMESTIC for
* intercity movements; the template layer labels those INTERCITY to match the
* commercial vocabulary used on the printed documents.
* The ten canonical contract document templates. Import and export split by
* customs clearing (× freight type = 8); intercity does not, because it is a
* purely domestic Ethiopian movement that crosses no border and therefore has
* no customs leg at all (× freight type = 2).
*
* 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); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* its own declarations.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK",
"EXPORT_BULK",
"IMPORT_BULK_CUSTOMS",
"IMPORT_BULK_NO_CUSTOMS",
"EXPORT_BULK_CUSTOMS",
"EXPORT_BULK_NO_CUSTOMS",
"INTERCITY_BULK",
"IMPORT_CONTAINER",
"EXPORT_CONTAINER",
"IMPORT_CONTAINER_CUSTOMS",
"IMPORT_CONTAINER_NO_CUSTOMS",
"EXPORT_CONTAINER_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
] as const;
@@ -33,10 +46,19 @@ export interface ContractTemplateArticle {
order: number;
}
/** Map a contract's stored direction/freight pair onto a template code. */
/**
* 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,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
@@ -46,7 +68,11 @@ export function contractTemplateCodeFor(
: "INTERCITY";
const freight =
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
return `${direction}_${freight}` as ContractTemplateCode;
if (direction === "INTERCITY") {
return `INTERCITY_${freight}` as ContractTemplateCode;
}
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
}
@Entity({ schema: "freight", name: "contract_templates" })

View File

@@ -423,6 +423,7 @@ export class ContractTransitionService {
const active = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled,
);
if (!active) return null;
return {