mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
dynamic contract templates and companyId on bookings
This commit is contained in:
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,37 +1,44 @@
|
|||||||
export interface ContractTemplateMeta {
|
import {
|
||||||
key: string;
|
article1ObjectiveFromClause,
|
||||||
title: string;
|
buildClausePack,
|
||||||
directionLabel: string;
|
} from './contract-clause-packs';
|
||||||
freightLabel: string;
|
import type {
|
||||||
currency: string;
|
ContractDirection,
|
||||||
serviceScope: 'TRANSPORT_ONLY' | 'FORWARDING';
|
ContractFreight,
|
||||||
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
|
ContractServiceScope,
|
||||||
templateFile?: string;
|
ContractTemplateMeta,
|
||||||
whereas: string;
|
} from './contract-template.types';
|
||||||
article1Objective: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIRECTION_LABELS: Record<string, string> = {
|
export type { ContractTemplateMeta } from './contract-template.types';
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<ContractDirection, string> = {
|
||||||
IMP: 'Import',
|
IMP: 'Import',
|
||||||
EXP: 'Export',
|
EXP: 'Export',
|
||||||
DOM: 'Domestic',
|
DOM: 'Domestic',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FREIGHT_LABELS: Record<string, string> = {
|
const FREIGHT_LABELS: Record<ContractFreight, string> = {
|
||||||
CON: 'Container',
|
CON: 'Container',
|
||||||
BULK: 'Bulk',
|
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(
|
function buildMeta(
|
||||||
dir: string,
|
dir: ContractDirection,
|
||||||
freight: string,
|
freight: ContractFreight,
|
||||||
currency: string,
|
currency: string,
|
||||||
service: 'TRANSPORT_ONLY' | 'FORWARDING',
|
service: ContractServiceScope,
|
||||||
templateFile?: string,
|
|
||||||
): ContractTemplateMeta {
|
): ContractTemplateMeta {
|
||||||
const key = `${dir}_${freight}_${currency}_${service}`;
|
const key = `${dir}_${freight}_${currency}_${service}`;
|
||||||
const dirLabel = DIRECTION_LABELS[dir] ?? dir;
|
const dirLabel = DIRECTION_LABELS[dir];
|
||||||
const freightLabel = FREIGHT_LABELS[freight] ?? freight;
|
const freightLabel = FREIGHT_LABELS[freight];
|
||||||
const serviceLabel =
|
const serviceLabel =
|
||||||
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
|
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
|
||||||
|
|
||||||
@@ -42,24 +49,26 @@ function buildMeta(
|
|||||||
? 'from Ethiopian dry ports to SGTD and related export corridors'
|
? 'from Ethiopian dry ports to SGTD and related export corridors'
|
||||||
: 'between designated Ethiopian rail terminals';
|
: 'between designated Ethiopian rail terminals';
|
||||||
|
|
||||||
|
const clauses = buildClausePack(dir, freight, service);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
|
direction: dir,
|
||||||
|
freight,
|
||||||
|
currency,
|
||||||
|
serviceScope: service,
|
||||||
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
|
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
|
||||||
directionLabel: dirLabel,
|
directionLabel: dirLabel,
|
||||||
freightLabel,
|
freightLabel,
|
||||||
currency,
|
|
||||||
serviceScope: service,
|
|
||||||
templateFile,
|
|
||||||
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
|
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti 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). */
|
/** Full template matrix (24 keys). */
|
||||||
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
|
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
|
||||||
{};
|
{};
|
||||||
@@ -68,31 +77,43 @@ for (const dir of DIRECTIONS) {
|
|||||||
for (const freight of FREIGHTS) {
|
for (const freight of FREIGHTS) {
|
||||||
for (const currency of CURRENCIES) {
|
for (const currency of CURRENCIES) {
|
||||||
for (const service of SERVICES) {
|
for (const service of SERVICES) {
|
||||||
const dedicated =
|
const meta = buildMeta(dir, freight, currency, service);
|
||||||
dir === 'IMP' &&
|
|
||||||
freight === 'CON' &&
|
|
||||||
currency === 'ETB' &&
|
|
||||||
service === 'TRANSPORT_ONLY'
|
|
||||||
? 'IMP_CON_ETB_TRANSPORT_ONLY.hbs'
|
|
||||||
: undefined;
|
|
||||||
const meta = buildMeta(dir, freight, currency, service, dedicated);
|
|
||||||
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
|
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTemplateMeta(key: string): ContractTemplateMeta {
|
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
|
||||||
return (
|
|
||||||
CONTRACT_TEMPLATE_REGISTRY[key] ?? {
|
export function listTemplateKeys(): string[] {
|
||||||
key,
|
return CONTRACT_TEMPLATE_KEYS;
|
||||||
title: 'Freight Contract Agreement',
|
}
|
||||||
directionLabel: 'Freight',
|
|
||||||
freightLabel: 'Cargo',
|
export function getTemplateMeta(key: string): ContractTemplateMeta {
|
||||||
currency: 'USD',
|
const found = CONTRACT_TEMPLATE_REGISTRY[key];
|
||||||
serviceScope: 'TRANSPORT_ONLY',
|
if (found) return found;
|
||||||
whereas: 'The parties agree to railway freight services as described in the schedule below.',
|
|
||||||
article1Objective: 'To provide railway transportation services per the agreed schedule.',
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -81,12 +81,18 @@ export class ContractViewModelBuilder {
|
|||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
client: {
|
client: {
|
||||||
companyName: booking.customer?.companyName ?? 'Client',
|
// companyName: booking.customer?.companyName ?? 'Client',
|
||||||
companyAddress: booking.customer?.companyAddress ?? '—',
|
// companyAddress: booking.customer?.companyAddress ?? '—',
|
||||||
companyLocation: booking.customer?.companyLocation ?? '—',
|
// companyLocation: booking.customer?.companyLocation ?? '—',
|
||||||
phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
|
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
|
||||||
email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
|
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
|
||||||
tinNumber: booking.customer?.tinNumber ?? '—',
|
// 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,
|
pricing,
|
||||||
signatures,
|
signatures,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="article">
|
||||||
|
<h2>Article 6: Contract Documents</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each template.contractDocuments}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -14,29 +14,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
|
<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>
|
<h2>Whereas</h2>
|
||||||
<p>{{template.whereas}}</p>
|
<p>{{template.whereas}}</p>
|
||||||
<p>Now therefore, the parties agree as follows:</p>
|
<p>Now therefore, the parties agree as follows:</p>
|
||||||
|
|
||||||
<div class="article">
|
{{> article1}}
|
||||||
<h2>Article 1: Objective and Scope</h2>
|
{{> articles_obligations}}
|
||||||
<p>{{template.article1Objective}}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{> article5_pricing}}
|
{{> article5_pricing}}
|
||||||
|
{{> force_majeure}}
|
||||||
<div class="article">
|
{{> contract_documents}}
|
||||||
<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>
|
|
||||||
|
|
||||||
{{> signatures_block}}
|
{{> signatures_block}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ export class AddFanNumberToCompanies1749300000000 implements MigrationInterface
|
|||||||
name = 'AddFanNumberToCompanies1749300000000';
|
name = 'AddFanNumberToCompanies1749300000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// fan_number may already exist when CreateCompaniesModule ran with the full schema
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
ALTER TABLE freight.companies
|
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> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`
|
await queryRunner.query(`
|
||||||
ALTER TABLE freight.companies
|
ALTER TABLE freight.companies
|
||||||
DROP COLUMN fan_number;
|
DROP COLUMN IF EXISTS fan_number;
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
@@ -41,7 +42,8 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
|||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CustomersModule,
|
CompaniesModule,
|
||||||
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PaymentsWebhookController],
|
controllers: [BookingsController, PaymentsWebhookController],
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||||
.leftJoinAndSelect('booking.customer', 'customer')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||||
.leftJoinAndSelect('booking.train', 'train')
|
.leftJoinAndSelect('booking.train', 'train')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'st')
|
.leftJoinAndSelect('booking.serviceType', 'st')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
@@ -351,7 +352,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.customer', 'customer')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.where('booking.status IN (:...statuses)', { statuses });
|
.where('booking.status IN (:...statuses)', { statuses });
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
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 { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
@@ -30,7 +31,8 @@ export class BookingsService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly customersService: CustomersService,
|
// private readonly customersService: CustomersService,
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly consolidationService: ConsolidationService,
|
private readonly consolidationService: ConsolidationService,
|
||||||
@@ -154,15 +156,26 @@ export class BookingsService {
|
|||||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
let customerId = dto.customerId;
|
// let customerId = dto.customerId;
|
||||||
if (!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) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
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);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
customerId = customer.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
const reference = dto.reference || (await this.generateReference());
|
||||||
@@ -196,7 +209,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
customerId,
|
companyId,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
@@ -375,7 +388,8 @@ export class BookingsService {
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.status) where.status = filter.status;
|
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.contractType) where.contractType = filter.contractType;
|
||||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||||
@@ -399,7 +413,8 @@ export class BookingsService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
order: { [sortField]: sortDir },
|
order: { [sortField]: sortDir },
|
||||||
relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
|
||||||
|
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
||||||
});
|
});
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,15 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
reference?: string;
|
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()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
customerId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...BOOKING_STATUSES])
|
@IsIn([...BOOKING_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
// @ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsUUID()
|
||||||
|
// customerId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
customerId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
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 { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.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 })
|
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||||
reference!: string;
|
reference!: string;
|
||||||
|
|
||||||
@Column({ name: 'customer_id', type: 'uuid' })
|
// Legacy — superseded by companyId (column kept in DB)
|
||||||
customerId!: string;
|
// @Column({ name: 'customer_id', type: 'uuid' })
|
||||||
|
// customerId!: string;
|
||||||
|
// @ManyToOne(() => Customer)
|
||||||
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
|
// customer?: Customer;
|
||||||
|
|
||||||
@ManyToOne(() => Customer)
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
@JoinColumn({ name: 'customer_id' })
|
companyId!: string;
|
||||||
customer?: Customer;
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company?: Company;
|
||||||
|
|
||||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
|||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
reference: booking.reference,
|
reference: booking.reference,
|
||||||
customerLabel: labelFromRef(booking.customer, booking.customerId),
|
customerLabel: labelFromRef(booking.company, booking.companyId),
|
||||||
|
// customerLabel: labelFromRef(booking.customer, booking.customerId),
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
scheduledDate: booking.scheduledDate,
|
scheduledDate: booking.scheduledDate,
|
||||||
totalAmount: Number(booking.totalAmount),
|
totalAmount: Number(booking.totalAmount),
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const B = URL_CONSTANTS.BOOKINGS;
|
|||||||
|
|
||||||
export interface BookingListFilter {
|
export interface BookingListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
customerId?: string;
|
// customerId?: string;
|
||||||
|
companyId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export interface BookingFile {
|
|||||||
export interface BookingDetail {
|
export interface BookingDetail {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
customerId: string;
|
// customerId: string;
|
||||||
|
companyId: string;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
@@ -91,7 +92,8 @@ export interface BookingDetail {
|
|||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
customer?: BookingNamedRef & { companyName?: string };
|
// customer?: BookingNamedRef & { companyName?: string };
|
||||||
|
company?: BookingNamedRef;
|
||||||
originYard?: BookingNamedRef;
|
originYard?: BookingNamedRef;
|
||||||
destinationYard?: BookingNamedRef;
|
destinationYard?: BookingNamedRef;
|
||||||
serviceType?: BookingNamedRef & { code?: string };
|
serviceType?: BookingNamedRef & { code?: string };
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -120,7 +121,6 @@ export default function NewBookingPage() {
|
|||||||
const group = cargoTree.find(
|
const group = cargoTree.find(
|
||||||
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
|
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
|
||||||
);
|
);
|
||||||
console.log(group, cargoTree);
|
|
||||||
return group?.id ?? "";
|
return group?.id ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,14 +132,14 @@ export default function NewBookingPage() {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const cargoTypeId = cargoTree[0].id;
|
const cargoTypeId =
|
||||||
// data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
// ? findContainerCargoTypeId()
|
? findContainerCargoTypeId()
|
||||||
// : (findCargoTypeId(
|
: (findCargoTypeId(
|
||||||
// data.freightType === "bulk"
|
data.freightType === "bulk"
|
||||||
// ? data.bulkCommodity
|
? data.bulkCommodity
|
||||||
// : data.breakBulkType,
|
: data.breakBulkType,
|
||||||
// ) ?? "");
|
) ?? "");
|
||||||
|
|
||||||
const cargoFreeText =
|
const cargoFreeText =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
@@ -168,7 +168,11 @@ export default function NewBookingPage() {
|
|||||||
: direction === "domestic"
|
: direction === "domestic"
|
||||||
? "DOMESTIC"
|
? "DOMESTIC"
|
||||||
: "IMPORT",
|
: "IMPORT",
|
||||||
cargoTypeId,
|
freightType:
|
||||||
|
data.cargoType === "container"
|
||||||
|
? Freight.FreightType.Container
|
||||||
|
: Freight.FreightType.Bulk,
|
||||||
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
@@ -181,7 +185,7 @@ export default function NewBookingPage() {
|
|||||||
vgmPerUnitTons: Number(c.vgm || 0),
|
vgmPerUnitTons: Number(c.vgm || 0),
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
...(customer ? { customerId: customer.id } : {}),
|
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
|
||||||
...(data.previousContractRef
|
...(data.previousContractRef
|
||||||
? { previousContractId: data.previousContractRef }
|
? { previousContractId: data.previousContractRef }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export interface CreateBookingContainerDto {
|
|||||||
export interface CreateBookingDto {
|
export interface CreateBookingDto {
|
||||||
reference?: string;
|
reference?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
companyId?: string;
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
contractType: "NEW" | "RENEWAL";
|
contractType: "NEW" | "RENEWAL";
|
||||||
|
|||||||
Reference in New Issue
Block a user