mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1104 from Tria-plc/freight_feature/usermanagement
add custom contrat templates
This commit is contained in:
@@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* The codes that gain a customs variant. Intercity is deliberately absent: it
|
||||
* is a domestic Ethiopian movement that crosses no border, so it has no customs
|
||||
* leg and keeps its single unsuffixed template.
|
||||
*/
|
||||
const SPLIT_CODES = [
|
||||
'IMPORT_BULK',
|
||||
'EXPORT_BULK',
|
||||
'IMPORT_CONTAINER',
|
||||
'EXPORT_CONTAINER',
|
||||
];
|
||||
|
||||
/**
|
||||
* Split the four cross-border contract templates into eight — one `_CUSTOMS`
|
||||
* and one `_NO_CUSTOMS` variant each — so the generated contract document
|
||||
* reflects whether EDR clears customs on the Client's behalf. Together with the
|
||||
* two untouched intercity templates the table ends up with ten rows.
|
||||
*
|
||||
* The four existing rows are RENAMED to `<code>_NO_CUSTOMS` rather than
|
||||
* replaced, so any article text staff already edited through the template
|
||||
* editor survives. The four `_CUSTOMS` rows are then inserted from the seed
|
||||
* (the same base pack plus the customs-clearing articles).
|
||||
*
|
||||
* Idempotent: the rename is guarded on the legacy code still existing, and the
|
||||
* insert is ON CONFLICT (code) DO NOTHING.
|
||||
*/
|
||||
export class SplitContractTemplatesByCustoms3230000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. Carry each legacy row over to its _NO_CUSTOMS code, preserving edits.
|
||||
// Guarded so a re-run (or a DB already holding the new code) is a no-op.
|
||||
for (const legacy of SPLIT_CODES) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates
|
||||
SET code = $2, updated_at = now()
|
||||
WHERE code = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates WHERE code = $2
|
||||
);
|
||||
`,
|
||||
[legacy, `${legacy}_NO_CUSTOMS`],
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Seed anything still missing — the six _CUSTOMS rows on an existing DB,
|
||||
// or all twelve on a database that never held the legacy codes.
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const articles = seed.articles.map((article, index) => ({
|
||||
...article,
|
||||
order: index + 1,
|
||||
}));
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.contract_templates
|
||||
(code, name, description, document_title, whereas_clauses, articles)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(articles),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the _CUSTOMS rows and fold the _NO_CUSTOMS rows back onto the legacy
|
||||
* codes, returning the table to six templates. The two intercity rows were
|
||||
* never touched by up(), so they need no reversal.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const legacy of SPLIT_CODES) {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = $1;`,
|
||||
[`${legacy}_CUSTOMS`],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates
|
||||
SET code = $1, updated_at = now()
|
||||
WHERE code = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates WHERE code = $1
|
||||
);
|
||||
`,
|
||||
[legacy, `${legacy}_NO_CUSTOMS`],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -423,6 +423,7 @@ export class ContractTransitionService {
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
} from "../../modules/contract-templates/entities/contract-template.entity";
|
||||
|
||||
/**
|
||||
* Default article packs for the six contract templates, transcribed from the
|
||||
* Default article packs for the ten contract templates, transcribed from the
|
||||
* signed EDR contract documents (test/contrat_docs). Article bodies use the
|
||||
* dynamic-article text format: one clause per line, "- " prefix for bullets
|
||||
* nested under the previous clause, single-line body = plain paragraph.
|
||||
@@ -20,6 +20,13 @@ export interface ContractTemplateSeed {
|
||||
articles: Array<Omit<ContractTemplateArticle, "order">>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A base pack keyed by direction/freight only. Each one is transcribed from a
|
||||
* signed EDR contract and is split at the bottom of this file into the
|
||||
* `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores.
|
||||
*/
|
||||
type ContractTemplateBase = Omit<ContractTemplateSeed, "code">;
|
||||
|
||||
const a = (id: string, title: string, body: string): Omit<ContractTemplateArticle, "order"> => ({
|
||||
id,
|
||||
title,
|
||||
@@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit<ContractTemplateArticl
|
||||
|
||||
/* ────────────────────────────── IMPORT / BULK ────────────────────────────── */
|
||||
|
||||
const IMPORT_BULK: ContractTemplateSeed = {
|
||||
code: "IMPORT_BULK",
|
||||
const IMPORT_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Import Contract",
|
||||
description:
|
||||
"Import of bulk cargo (e.g. steel billets) from Djibouti (DMP/Nagad) to Galaan Multipurpose Port with customs clearance and optional last-mile delivery.",
|
||||
@@ -158,8 +164,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ────────────────────────────── EXPORT / BULK ────────────────────────────── */
|
||||
|
||||
const EXPORT_BULK: ContractTemplateSeed = {
|
||||
code: "EXPORT_BULK",
|
||||
const EXPORT_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Export Contract",
|
||||
description:
|
||||
"Export of bulk cargo (e.g. livestock) by railway from Ethiopian loading stations to Nagad railway freight yard, Djibouti.",
|
||||
@@ -294,8 +299,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ──────────────────────────── INTERCITY / BULK ───────────────────────────── */
|
||||
|
||||
const INTERCITY_BULK: ContractTemplateSeed = {
|
||||
code: "INTERCITY_BULK",
|
||||
const INTERCITY_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Intercity Contract",
|
||||
description:
|
||||
"Domestic (intercity) bulk cargo transportation by railway between Ethiopian freight yards, e.g. Dire Dawa to Sebeta.",
|
||||
@@ -418,8 +422,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ──────────────────────────── IMPORT / CONTAINER ─────────────────────────── */
|
||||
|
||||
const IMPORT_CONTAINER: ContractTemplateSeed = {
|
||||
code: "IMPORT_CONTAINER",
|
||||
const IMPORT_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Import Contract",
|
||||
description:
|
||||
"Import container transport by railway from SGTD (Djibouti) to Dire Dawa, Modjo dry port, or Galaan Multipurpose Port, with empty-container return.",
|
||||
@@ -568,8 +571,7 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
|
||||
|
||||
/* ──────────────────────────── EXPORT / CONTAINER ─────────────────────────── */
|
||||
|
||||
const EXPORT_CONTAINER: ContractTemplateSeed = {
|
||||
code: "EXPORT_CONTAINER",
|
||||
const EXPORT_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Export Contract",
|
||||
description:
|
||||
"Export container transport, freight forwarding, and customs clearing from Galaan Multipurpose Port or Modjo dry port to SGTD container freight station (Djibouti).",
|
||||
@@ -717,8 +719,7 @@ The signatories confirm that they are fully authorized to sign and execute this
|
||||
|
||||
/* ─────────────────────────── INTERCITY / CONTAINER ───────────────────────── */
|
||||
|
||||
const INTERCITY_CONTAINER: ContractTemplateSeed = {
|
||||
code: "INTERCITY_CONTAINER",
|
||||
const INTERCITY_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Intercity Contract",
|
||||
description:
|
||||
"Domestic (intercity) container transport by railway between Ethiopian terminals — Galaan Multipurpose Port, Modjo dry port, and Dire Dawa — including empty repositioning.",
|
||||
@@ -854,11 +855,67 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
|
||||
],
|
||||
};
|
||||
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
IMPORT_BULK,
|
||||
EXPORT_BULK,
|
||||
INTERCITY_BULK,
|
||||
IMPORT_CONTAINER,
|
||||
EXPORT_CONTAINER,
|
||||
INTERCITY_CONTAINER,
|
||||
/* ─────────────────────── CUSTOMS / NO-CUSTOMS SPLIT ──────────────────────── */
|
||||
|
||||
/**
|
||||
* Articles appended to the `_CUSTOMS` variant of every base pack. The signed
|
||||
* source documents fold customs duties into the body prose rather than a
|
||||
* dedicated article, so these state the clearing obligations explicitly for the
|
||||
* contracts where EDR clears on the Client's behalf.
|
||||
*/
|
||||
const CUSTOMS_ARTICLES: Array<Omit<ContractTemplateArticle, "order">> = [
|
||||
a(
|
||||
"customs-clearing",
|
||||
"Customs Clearing Services",
|
||||
`The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement, including declaration, lodgement, and follow-up at the customs stations of Djibouti and Ethiopia as applicable to the agreed corridor.
|
||||
The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction.
|
||||
Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance.
|
||||
The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`,
|
||||
),
|
||||
a(
|
||||
"customs-client-duties",
|
||||
"Client Obligations for Customs Clearing",
|
||||
`Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent for the duration of this Agreement.
|
||||
Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request.
|
||||
Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate.
|
||||
Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation.
|
||||
Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`,
|
||||
),
|
||||
];
|
||||
|
||||
/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */
|
||||
function splitByCustoms(
|
||||
base: ContractTemplateBase,
|
||||
codeStem: string,
|
||||
): ContractTemplateSeed[] {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
code: `${codeStem}_CUSTOMS` as ContractTemplateCode,
|
||||
name: `${base.name} (with customs clearing)`,
|
||||
description: `${base.description} Customs clearing is performed by the Service Provider.`,
|
||||
articles: [...base.articles, ...CUSTOMS_ARTICLES],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode,
|
||||
name: `${base.name} (without customs clearing)`,
|
||||
description: `${base.description} Customs clearing is handled by the Client.`,
|
||||
articles: [...base.articles],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ten templates: import and export each split by customs clearing, intercity
|
||||
* not split at all — it is a domestic Ethiopian movement that crosses no
|
||||
* border, so there is no customs leg to contract for.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
|
||||
...splitByCustoms(EXPORT_BULK_BASE, "EXPORT_BULK"),
|
||||
{ ...INTERCITY_BULK_BASE, code: "INTERCITY_BULK" },
|
||||
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
|
||||
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
|
||||
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
|
||||
];
|
||||
|
||||
@@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string {
|
||||
return code.split("_")[0];
|
||||
}
|
||||
|
||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
||||
// the second one — never the suffix.
|
||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
return code.split("_")[1] === "BULK";
|
||||
}
|
||||
|
||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
||||
// "client clears its own customs" choice.
|
||||
function customsVariant(code: ContractTemplate["code"]): boolean | null {
|
||||
if (code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (code.endsWith("_CUSTOMS")) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
@@ -66,12 +77,12 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
: (templates ?? []).map((template) => (
|
||||
<TemplateCard
|
||||
key={template.code}
|
||||
@@ -104,6 +115,7 @@ function TemplateCard({
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
const customs = customsVariant(template.code);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -139,13 +151,29 @@ function TemplateCard({
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{customs !== null && (
|
||||
<Tooltip
|
||||
label={
|
||||
customs
|
||||
? "Used when the contract has customs clearing enabled"
|
||||
: "Used when the client handles its own customs clearing"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
|
||||
{customs ? "With customs" : "No customs"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Name + description */}
|
||||
|
||||
@@ -11,12 +11,18 @@ export interface ContractTemplateArticle {
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
||||
// border, and so has a single template.
|
||||
code:
|
||||
| "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";
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user