dynamic contract templates and companyId on bookings

This commit is contained in:
marshal
2026-06-04 16:53:14 +03:00
parent 324935a334
commit 8fea963ddb
26 changed files with 724 additions and 181 deletions

View File

@@ -0,0 +1,224 @@
import type {
Article1Clause,
ContractClausePack,
ContractDirection,
ContractFreight,
ContractServiceScope,
} from './contract-template.types';
const STANDARD_CONTRACT_DOCUMENTS = [
'Amendments (if any)',
'This Contract Agreement',
'Final Minutes of Negotiation (if any)',
];
const PAYMENT_OBLIGATION =
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
const HAZARDOUS_OBLIGATION =
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
function clonePack(pack: ContractClausePack): ContractClausePack {
return {
article1: {
objective: pack.article1.objective,
scope: [...pack.article1.scope],
},
clientObligations: [...pack.clientObligations],
providerObligations: [...pack.providerObligations],
contractDocuments: [...pack.contractDocuments],
};
}
function applyForwardingOverlay(
pack: ContractClausePack,
service: ContractServiceScope,
): ContractClausePack {
if (service !== 'FORWARDING') return pack;
const next = clonePack(pack);
next.article1.scope.push(
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
);
next.providerObligations.push(
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
);
return next;
}
function buildImportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
scope: [
'Railway transport service on the agreed import corridor.',
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
],
},
clientObligations: [
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildImportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
scope: [
'Railway bulk transport service on the agreed import corridor.',
'Loading and unloading coordination at designated terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
'Ensure cargo is prepared and available at origin per the agreed loading window.',
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
scope: [
'Railway export transport service on the agreed corridor.',
'Terminal coordination at origin yards for export dispatch where applicable.',
],
},
clientObligations: [
'Provide export shipment instructions and container release details for each movement.',
'Ensure containers are available at origin terminals per EDR operational windows.',
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
scope: [
'Railway bulk export transport on the agreed corridor.',
'Loading coordination at origin terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and export shipment instructions.',
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
'Submit required export and customs documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
},
clientObligations: [
'Provide shipment instructions for each domestic container movement.',
'Ensure containers are available at origin per EDR operational rules.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
},
clientObligations: [
'Provide commodity description, weight, and shipment instructions for each movement.',
'Ensure cargo is prepared at origin per the agreed loading window.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation within agreed timelines.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
IMP: {
CON: buildImportContainerPack,
BULK: buildImportBulkPack,
},
EXP: {
CON: buildExportContainerPack,
BULK: buildExportBulkPack,
},
DOM: {
CON: buildDomesticContainerPack,
BULK: buildDomesticBulkPack,
},
};
export function buildClausePack(
direction: ContractDirection,
freight: ContractFreight,
service: ContractServiceScope,
): ContractClausePack {
const base = BASE_PACKS[direction][freight]();
return applyForwardingOverlay(base, service);
}
export function article1ObjectiveFromClause(article1: Article1Clause): string {
return article1.objective;
}

View File

@@ -0,0 +1,57 @@
import { ContractRendererService } from './contract-renderer.service';
import { getTemplateMeta } from './contract-template.registry';
import type { ContractViewModel } from './contract-view-model.builder';
describe('ContractRendererService', () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
function minimalView(templateKey: string): ContractViewModel {
const template = getTemplateMeta(templateKey);
return {
bookingId: 'test-id',
reference: 'BK-TEST-001',
status: 'CONTRACT_READY',
templateKey,
template,
contractDate: '1 January 2026',
contractYear: 2026,
client: {
companyName: 'Test Co',
companyAddress: 'Addis Ababa',
companyLocation: 'Ethiopia',
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
},
pricing: {
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
surcharges: [],
totalAmount: 1000,
currency: 'ETB',
originLabel: 'SGTD',
destinationLabel: 'Modjo',
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
},
signatures: [],
canSignCustomer: true,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
};
}
it('renders import flagship with Nagad and Article 5', () => {
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
expect(html).toContain('Djibouti Nagad');
expect(html).toContain('Article 5: Contract Price');
expect(html).toContain('Article 2: Obligations of the Client');
});
it('renders export variant without import empty-return clause', () => {
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
expect(html).toContain('export corridors');
expect(html).not.toContain('Return empty containers from Dire Dawa');
});
});

View File

@@ -0,0 +1,68 @@
import {
CONTRACT_TEMPLATE_KEYS,
CONTRACT_TEMPLATE_REGISTRY,
getTemplateMeta,
isValidTemplateKey,
} from './contract-template.registry';
describe('ContractTemplateRegistry', () => {
it('defines exactly 24 template keys', () => {
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
});
it('keys match the direction_freight_currency_service pattern', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
expect(isValidTemplateKey(key)).toBe(true);
}
});
it('each meta has non-empty obligations and article1 scope', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.providerObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
expect(meta.article1.objective.length).toBeGreaterThan(0);
expect(meta.contractDocuments.length).toBeGreaterThan(0);
}
});
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
expect(meta.direction).toBe('IMP');
expect(meta.freight).toBe('CON');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.article1.objective).toContain('empty container return');
const clientText = meta.clientObligations.join(' ');
expect(clientText).toContain('Djibouti Nagad');
const providerText = meta.providerObligations.join(' ');
expect(providerText).toContain('seven (7) calendar days');
});
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
expect(meta.direction).toBe('EXP');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.providerObligations.join(' ')).not.toContain(
'Return empty containers from Dire Dawa',
);
});
it('FORWARDING adds scope and provider obligations', () => {
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
expect(forwarding.article1.scope.length).toBeGreaterThan(
transport.article1.scope.length,
);
expect(forwarding.providerObligations.length).toBeGreaterThan(
transport.providerObligations.length,
);
});
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
const meta = getTemplateMeta('UNKNOWN_KEY');
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
});
});

View File

@@ -1,37 +1,44 @@
export interface ContractTemplateMeta {
key: string;
title: string;
directionLabel: string;
freightLabel: string;
currency: string;
serviceScope: 'TRANSPORT_ONLY' | 'FORWARDING';
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
templateFile?: string;
whereas: string;
article1Objective: string;
}
import {
article1ObjectiveFromClause,
buildClausePack,
} from './contract-clause-packs';
import type {
ContractDirection,
ContractFreight,
ContractServiceScope,
ContractTemplateMeta,
} from './contract-template.types';
const DIRECTION_LABELS: Record<string, string> = {
export type { ContractTemplateMeta } from './contract-template.types';
const DIRECTION_LABELS: Record<ContractDirection, string> = {
IMP: 'Import',
EXP: 'Export',
DOM: 'Domestic',
};
const FREIGHT_LABELS: Record<string, string> = {
const FREIGHT_LABELS: Record<ContractFreight, string> = {
CON: 'Container',
BULK: 'Bulk',
};
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
const CURRENCIES = ['ETB', 'USD'] as const;
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
const KEY_PATTERN =
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
function buildMeta(
dir: string,
freight: string,
dir: ContractDirection,
freight: ContractFreight,
currency: string,
service: 'TRANSPORT_ONLY' | 'FORWARDING',
templateFile?: string,
service: ContractServiceScope,
): ContractTemplateMeta {
const key = `${dir}_${freight}_${currency}_${service}`;
const dirLabel = DIRECTION_LABELS[dir] ?? dir;
const freightLabel = FREIGHT_LABELS[freight] ?? freight;
const dirLabel = DIRECTION_LABELS[dir];
const freightLabel = FREIGHT_LABELS[freight];
const serviceLabel =
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
@@ -42,24 +49,26 @@ function buildMeta(
? 'from Ethiopian dry ports to SGTD and related export corridors'
: 'between designated Ethiopian rail terminals';
const clauses = buildClausePack(dir, freight, service);
return {
key,
direction: dir,
freight,
currency,
serviceScope: service,
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
directionLabel: dirLabel,
freightLabel,
currency,
serviceScope: service,
templateFile,
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis AbabaDjibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
article1Objective: `To provide railway transportation services for ${freightLabel.toLowerCase()} cargo on the agreed corridor (${serviceLabel}).`,
article1Objective: article1ObjectiveFromClause(clauses.article1),
article1: clauses.article1,
clientObligations: clauses.clientObligations,
providerObligations: clauses.providerObligations,
contractDocuments: clauses.contractDocuments,
};
}
const DIRECTIONS = ['IMP', 'EXP', 'DOM'] as const;
const FREIGHTS = ['CON', 'BULK'] as const;
const CURRENCIES = ['ETB', 'USD'] as const;
const SERVICES = ['TRANSPORT_ONLY', 'FORWARDING'] as const;
/** Full template matrix (24 keys). */
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
{};
@@ -68,31 +77,43 @@ for (const dir of DIRECTIONS) {
for (const freight of FREIGHTS) {
for (const currency of CURRENCIES) {
for (const service of SERVICES) {
const dedicated =
dir === 'IMP' &&
freight === 'CON' &&
currency === 'ETB' &&
service === 'TRANSPORT_ONLY'
? 'IMP_CON_ETB_TRANSPORT_ONLY.hbs'
: undefined;
const meta = buildMeta(dir, freight, currency, service, dedicated);
const meta = buildMeta(dir, freight, currency, service);
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
}
}
}
}
export function getTemplateMeta(key: string): ContractTemplateMeta {
return (
CONTRACT_TEMPLATE_REGISTRY[key] ?? {
key,
title: 'Freight Contract Agreement',
directionLabel: 'Freight',
freightLabel: 'Cargo',
currency: 'USD',
serviceScope: 'TRANSPORT_ONLY',
whereas: 'The parties agree to railway freight services as described in the schedule below.',
article1Objective: 'To provide railway transportation services per the agreed schedule.',
}
);
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
export function listTemplateKeys(): string[] {
return CONTRACT_TEMPLATE_KEYS;
}
export function getTemplateMeta(key: string): ContractTemplateMeta {
const found = CONTRACT_TEMPLATE_REGISTRY[key];
if (found) return found;
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
return {
key,
direction: 'IMP',
freight: 'CON',
currency: 'USD',
serviceScope: 'TRANSPORT_ONLY',
title: 'Freight Contract Agreement',
directionLabel: 'Freight',
freightLabel: 'Cargo',
whereas:
'The parties agree to railway freight services as described in the schedule below.',
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
article1: fallbackClauses.article1,
clientObligations: fallbackClauses.clientObligations,
providerObligations: fallbackClauses.providerObligations,
contractDocuments: fallbackClauses.contractDocuments,
};
}
export function isValidTemplateKey(key: string): boolean {
return KEY_PATTERN.test(key);
}

View File

@@ -0,0 +1,65 @@
import { ContractTemplateResolver } from './contract-template.resolver';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
describe('ContractTemplateResolver', () => {
const resolver = new ContractTemplateResolver();
function booking(partial: Partial<Booking>): Booking {
return partial as Booking;
}
it('resolves import container ETB transport-only', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
}),
);
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
});
it('resolves export bulk USD forwarding', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'EXPORT',
freightType: 'BULK',
paymentCurrency: 'USD',
serviceType: {
code: 'RAIL_FORWARDING',
includesFirstMile: true,
includesLastMile: false,
} as ServiceType,
}),
);
expect(key).toBe('EXP_BULK_USD_FORWARDING');
});
it('maps BREAK_BULK cargo to BULK freight', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
serviceType: undefined,
}),
);
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
});
it('resolves domestic container', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
paymentCurrency: 'USD',
serviceType: undefined,
}),
);
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
});
});

View File

@@ -0,0 +1,35 @@
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
export type ContractFreight = 'CON' | 'BULK';
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
export interface Article1Clause {
objective: string;
scope: string[];
}
export interface ContractClausePack {
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
}
export interface ContractTemplateMeta {
key: string;
direction: ContractDirection;
freight: ContractFreight;
currency: string;
serviceScope: ContractServiceScope;
title: string;
directionLabel: string;
freightLabel: string;
whereas: string;
/** Summary line for APIs; mirrors article1.objective */
article1Objective: string;
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
templateFile?: string;
}

View File

@@ -81,12 +81,18 @@ export class ContractViewModelBuilder {
}),
contractYear: new Date().getFullYear(),
client: {
companyName: booking.customer?.companyName ?? 'Client',
companyAddress: booking.customer?.companyAddress ?? '—',
companyLocation: booking.customer?.companyLocation ?? '—',
phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
tinNumber: booking.customer?.tinNumber ?? '—',
// companyName: booking.customer?.companyName ?? 'Client',
// companyAddress: booking.customer?.companyAddress ?? '—',
// companyLocation: booking.customer?.companyLocation ?? '—',
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
// tinNumber: booking.customer?.tinNumber ?? '—',
companyName: booking.company?.name ?? 'Client',
companyAddress: booking.company?.address ?? '—',
companyLocation: booking.company?.country ?? '—',
phone: booking.company?.phone ?? '—',
email: booking.company?.email ?? '—',
tinNumber: booking.company?.tin ?? '—',
},
pricing,
signatures,

View File

@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Import Container Transport — {{reference}}</title>
{{> styles}}
</head>
<body>
<div class="cover">
<h1>Contract Agreement</h1>
<h1>Import Container Transport Service by Railway</h1>
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
</div>
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p><strong>Objective:</strong> To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.</p>
<p><strong>Scope:</strong> (1) Railway transport service; (2) Cargo handling at Galaan Multipurpose port (GMP) where applicable.</p>
</div>
<div class="article">
<h2>Article 2: Obligations of the Client (summary)</h2>
<ol>
<li>Provide shipment instructions to EDR for container movements on the agreed corridor.</li>
<li>Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.</li>
<li>Submit required documents to Djibouti Nagad station at least 24 hours before loading.</li>
<li>Pay 100% transportation fees in advance per train set in {{paymentArticle}}.</li>
<li>Notify EDR 48 hours in advance for hazardous or valuable cargo.</li>
</ol>
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
<ol>
<li>Assign voyage per operational schedule and notify train schedule 48 hours in advance.</li>
<li>Provide safe transportation and deliver within agreed timelines when documents are complete.</li>
<li>Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.</li>
<li>Maintain cargo liability insurance per wagon.</li>
</ol>
</div>
{{> article5_pricing}}
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
</div>
<div class="article">
<h2>Article 6: Contract Documents</h2>
<ol>
<li>Amendments (if any)</li>
<li>This Contract Agreement</li>
<li>Final Minutes of Negotiation (if any)</li>
</ol>
</div>
{{> signatures_block}}
</body>
</html>

View File

@@ -0,0 +1,12 @@
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p><strong>Objective:</strong> {{template.article1.objective}}</p>
{{#if template.article1.scope.length}}
<p><strong>Scope:</strong></p>
<ol>
{{#each template.article1.scope}}
<li>{{this}}</li>
{{/each}}
</ol>
{{/if}}
</div>

View File

@@ -0,0 +1,17 @@
<div class="article">
<h2>Article 2: Obligations of the Client (summary)</h2>
<ol>
{{#each template.clientObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
<ol>
{{#each template.providerObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,8 @@
<div class="article">
<h2>Article 6: Contract Documents</h2>
<ol>
{{#each template.contractDocuments}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,4 @@
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
</div>

View File

@@ -14,29 +14,17 @@
</div>
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR) (“Service Provider”) and <strong>{{client.companyName}}</strong> (“Client”) at {{client.companyAddress}}, {{client.companyLocation}}. Phone: {{client.phone}}. Email: {{client.email}}. TIN: {{client.tinNumber}}.</p>
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
<div class="article">
<h2>Article 1: Objective and Scope</h2>
<p>{{template.article1Objective}}</p>
</div>
{{> article1}}
{{> articles_obligations}}
{{> article5_pricing}}
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party shall be liable for delays caused by force majeure beyond reasonable control, interpreted per the Ethiopian Civil Code.</p>
</div>
<div class="article">
<h2>Article 6: Contract Documents</h2>
<p>This agreement, amendments (if any), and negotiated minutes constitute the contract.</p>
</div>
{{> force_majeure}}
{{> contract_documents}}
{{> signatures_block}}
</body>
</html>

View File

@@ -4,16 +4,17 @@ export class AddFanNumberToCompanies1749300000000 implements MigrationInterface
name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// fan_number may already exist when CreateCompaniesModule ran with the full schema
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN fan_number varchar(16) NULL;
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN fan_number;
DROP COLUMN IF EXISTS fan_number;
`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
name = 'AddCompanyIdToBookings1749500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS company_id UUID;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
ON freight.bookings(company_id);
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
) THEN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_company_id"
FOREIGN KEY (company_id)
REFERENCES freight.companies(id);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
`);
await queryRunner.query(`
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id SET NOT NULL;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS company_id;
`);
}
}

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomersModule } from '../customers/customers.module';
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
@@ -41,7 +42,8 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
]),
FilesModule,
MinioModule,
CustomersModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController, PaymentsWebhookController],

View File

@@ -61,7 +61,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
@@ -351,7 +352,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.where('booking.status IN (:...statuses)', { statuses });

View File

@@ -6,7 +6,8 @@ import {
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { CustomersService } from '../customers/customers.service';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -30,7 +31,8 @@ export class BookingsService {
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -154,15 +156,26 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
let customerId = dto.customerId;
if (!customerId) {
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
// throw new BadRequestException(
// 'customerId is required or must be resolvable from auth token',
// );
// }
// const customer = await this.customersService.findByUserId(userId);
// customerId = customer.id;
// }
let companyId = dto.companyId;
if (!companyId) {
if (!userId) {
throw new BadRequestException(
'customerId is required or must be resolvable from auth token',
'companyId is required or must be resolvable from auth token',
);
}
const customer = await this.customersService.findByUserId(userId);
customerId = customer.id;
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
companyId = company.id;
}
const reference = dto.reference || (await this.generateReference());
@@ -196,7 +209,7 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
customerId,
companyId,
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
@@ -375,7 +388,8 @@ export class BookingsService {
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.customerId) where.customerId = filter.customerId;
// if (filter.customerId) where.customerId = filter.customerId;
if (filter.companyId) where.companyId = filter.companyId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
@@ -399,7 +413,8 @@ export class BookingsService {
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
});
return { items, total };
}

View File

@@ -61,10 +61,15 @@ export class CreateBookingDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@IsOptional()
@IsUUID()
customerId?: string;
companyId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()

View File

@@ -14,10 +14,15 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
// @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
companyId?: string;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Customer } from '../../customers/entities/customer.entity';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
@@ -60,12 +61,19 @@ export class Booking extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId!: string;
// Legacy — superseded by companyId (column kept in DB)
// @Column({ name: 'customer_id', type: 'uuid' })
// customerId!: string;
// @ManyToOne(() => Customer)
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@ManyToOne(() => Customer)
@JoinColumn({ name: 'customer_id' })
customer?: Customer;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'company_id' })
company?: Company;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;

View File

@@ -18,7 +18,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return {
id: booking.id,
reference: booking.reference,
customerLabel: labelFromRef(booking.customer, booking.customerId),
customerLabel: labelFromRef(booking.company, booking.companyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,
scheduledDate: booking.scheduledDate,
totalAmount: Number(booking.totalAmount),

View File

@@ -7,7 +7,8 @@ const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter {
status?: string;
customerId?: string;
// customerId?: string;
companyId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;

View File

@@ -70,7 +70,8 @@ export interface BookingFile {
export interface BookingDetail {
id: string;
reference: string;
customerId: string;
// customerId: string;
companyId: string;
status: BookingStatus;
scheduledDate: string;
totalAmount: number;
@@ -91,7 +92,8 @@ export interface BookingDetail {
latestChangeRequestNote?: string | null;
createdAt: string;
updatedAt: string;
customer?: BookingNamedRef & { companyName?: string };
// customer?: BookingNamedRef & { companyName?: string };
company?: BookingNamedRef;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string };

View File

@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
BookingFormInputValues,
@@ -120,7 +121,6 @@ export default function NewBookingPage() {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
console.log(group, cargoTree);
return group?.id ?? "";
};
@@ -132,14 +132,14 @@ export default function NewBookingPage() {
return "";
};
const cargoTypeId = cargoTree[0].id;
// data.cargoType === "container"
// ? findContainerCargoTypeId()
// : (findCargoTypeId(
// data.freightType === "bulk"
// ? data.bulkCommodity
// : data.breakBulkType,
// ) ?? "");
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: (findCargoTypeId(
data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
) ?? "");
const cargoFreeText =
data.cargoType === "container"
@@ -168,7 +168,11 @@ export default function NewBookingPage() {
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
cargoTypeId,
freightType:
data.cargoType === "container"
? Freight.FreightType.Container
: Freight.FreightType.Bulk,
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
@@ -181,7 +185,7 @@ export default function NewBookingPage() {
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
...(customer ? { customerId: customer.id } : {}),
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
...(data.previousContractRef
? { previousContractId: data.previousContractRef }
: {}),

View File

@@ -288,6 +288,7 @@ export interface CreateBookingContainerDto {
export interface CreateBookingDto {
reference?: string;
customerId?: string;
companyId?: string;
trainId?: string;
scheduledDate: string;
contractType: "NEW" | "RENEWAL";