mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
- Implemented a method in to permanently delete wagons without history. - Added corresponding permissions for hard delete actions in . - Updated the UI components to include purge actions, ensuring they are only available to users with the appropriate permissions. - Created modals for confirming permanent deletions in and . - Enhanced API services to handle purge requests for locomotives, wagons, and routes. - Added tests for the purge functionality in both and services to ensure proper behavior and error handling.
268 lines
9.8 KiB
TypeScript
268 lines
9.8 KiB
TypeScript
import { parseArticleBody, interpolateTemplateText } from './contract-article.util';
|
||
import { ContractRendererService } from './contract-renderer.service';
|
||
import { getTemplateMeta } from './contract-template.registry';
|
||
import type { ContractViewModel } from './contract-view-model.builder';
|
||
|
||
describe('parseArticleBody', () => {
|
||
it('numbers each non-empty line as a clause', () => {
|
||
const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.');
|
||
expect(parsed.paragraph).toBeUndefined();
|
||
expect(parsed.clauses.map((c) => c.text)).toEqual([
|
||
'First clause.',
|
||
'Second clause.',
|
||
'Third clause.',
|
||
]);
|
||
});
|
||
|
||
it('nests "- " lines as bullets under the previous clause', () => {
|
||
const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.');
|
||
expect(parsed.clauses).toHaveLength(2);
|
||
expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']);
|
||
expect(parsed.clauses[1].text).toBe('Payment in advance.');
|
||
});
|
||
|
||
it('renders a single bare line as a paragraph', () => {
|
||
const parsed = parseArticleBody('This Agreement becomes effective when signed.');
|
||
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
|
||
expect(parsed.clauses).toEqual([]);
|
||
});
|
||
|
||
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
|
||
const parsed = parseArticleBody(
|
||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||
);
|
||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||
['1', 1, 'Scope'],
|
||
['a', 2, 'Rail transport'],
|
||
['i', 3, 'Wagon supply'],
|
||
['2', 1, 'Payment'],
|
||
]);
|
||
});
|
||
|
||
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
|
||
const parsed = parseArticleBody(
|
||
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
|
||
);
|
||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||
['1', 1],
|
||
['a', 2],
|
||
['b', 2],
|
||
['i', 3],
|
||
['ii', 3],
|
||
['1', 4],
|
||
]);
|
||
});
|
||
|
||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||
['1', 1],
|
||
['2', 1],
|
||
]);
|
||
});
|
||
|
||
it('leaves prose that merely starts with a number un-tokenized', () => {
|
||
const parsed = parseArticleBody('10 tons is the minimum load.\nPayment in advance.');
|
||
expect(parsed.clauses.map((c) => c.text)).toEqual([
|
||
'10 tons is the minimum load.',
|
||
'Payment in advance.',
|
||
]);
|
||
});
|
||
|
||
it('keeps a single explicitly numbered line as a clause, not a paragraph', () => {
|
||
const parsed = parseArticleBody('1. Only clause.');
|
||
expect(parsed.paragraph).toBeUndefined();
|
||
expect(parsed.clauses.map((c) => [c.number, c.text])).toEqual([['1', 'Only clause.']]);
|
||
});
|
||
});
|
||
|
||
describe('interpolateTemplateText', () => {
|
||
it('fills placeholders from the view model', () => {
|
||
expect(
|
||
interpolateTemplateText('Valid until August 31, {{contractYear}}.', {
|
||
contractYear: 2026,
|
||
}),
|
||
).toBe('Valid until August 31, 2026.');
|
||
});
|
||
|
||
it('falls back to raw text on malformed placeholders', () => {
|
||
expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag');
|
||
});
|
||
});
|
||
|
||
describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||
const renderer = new ContractRendererService();
|
||
renderer.onModuleInit();
|
||
|
||
function dynamicView(): ContractViewModel {
|
||
const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING');
|
||
return {
|
||
bookingId: 'test-id',
|
||
reference: 'EDR/CT/2026/0042',
|
||
status: 'CONTRACT_READY',
|
||
templateKey: 'IMP_BULK_USD_FORWARDING',
|
||
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
||
contractDate: '1 January 2026',
|
||
contractYear: 2026,
|
||
contractStartDate: '1 January 2026',
|
||
contractEndDate: '31 December 2026',
|
||
client: {
|
||
companyName: 'Abyssinia Trading PLC',
|
||
companyAddress: 'Bole Sub-city, Addis Ababa',
|
||
companyLocation: 'Ethiopia',
|
||
phone: '+251900000000',
|
||
email: 'test@example.com',
|
||
tinNumber: '1234567890',
|
||
vatNumber: 'VAT-001',
|
||
fanNumber: 'FAN-001',
|
||
businessLicense: 'BL-001',
|
||
},
|
||
provider: {
|
||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||
address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia',
|
||
phone: '+251 11 872 0000',
|
||
email: 'info@edr.gov.et',
|
||
tinNumber: '—',
|
||
},
|
||
schedule: {
|
||
originLabel: 'Nagad',
|
||
destinationLabel: 'Galaan Multipurpose Port',
|
||
tradeDirection: 'IMPORT',
|
||
freightType: 'BULK',
|
||
serviceType: 'Rail + clearance',
|
||
scheduledDate: '—',
|
||
contractType: 'GENERAL',
|
||
cargoDescription: 'Steel billets',
|
||
cargoTypeName: 'Steel billets',
|
||
containerType: '—',
|
||
cargoSummary: 'Steel billets × 2,800',
|
||
totalWeightVgm: '—',
|
||
equipmentReturn: '—',
|
||
hazardousLabel: 'No',
|
||
firstMilePickupAddress: '—',
|
||
lastMileDeliveryAddress: '—',
|
||
},
|
||
pricing: {
|
||
displayMode: 'UNIT_RATES',
|
||
unitRates: [
|
||
{ label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' },
|
||
],
|
||
currency: 'USD',
|
||
equipmentReturn: '—',
|
||
originLabel: 'Nagad',
|
||
destinationLabel: 'Galaan Multipurpose Port',
|
||
} as unknown as ContractViewModel['pricing'],
|
||
rateSchedule: {
|
||
freightLanes: [
|
||
{ route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' },
|
||
],
|
||
additionalServices: [
|
||
{ route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' },
|
||
],
|
||
surcharges: [],
|
||
isEmpty: false,
|
||
currencyLabel: 'USD',
|
||
},
|
||
signatures: [],
|
||
canSignCustomer: false,
|
||
canSignStaff: false,
|
||
hasContractDocument: false,
|
||
hasCustomerSignature: false,
|
||
hasStaffSignature: false,
|
||
dynamicTemplate: {
|
||
code: 'IMPORT_BULK',
|
||
name: 'Bulk Import Contract',
|
||
documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services',
|
||
whereasClauses: ['The Client has agreed to engage the Service Provider.'],
|
||
articles: [
|
||
{
|
||
id: 'objective',
|
||
title: 'Objective of the Services',
|
||
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
|
||
order: 1,
|
||
},
|
||
{
|
||
id: 'pricing',
|
||
title: 'Contract Price and Payment Terms',
|
||
body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.',
|
||
order: 2,
|
||
},
|
||
{
|
||
id: 'duration',
|
||
title: 'Duration',
|
||
body: 'Valid until August 31, {{contractYear}}.',
|
||
order: 3,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
it('renders numbered dynamic articles with bullets and interpolation', () => {
|
||
const html = renderer.render(dynamicView());
|
||
expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services');
|
||
expect(html).toContain('Article 1');
|
||
expect(html).toContain('Objective of the Services');
|
||
expect(html).toContain('Rail transport to GMP');
|
||
expect(html).toContain('Valid until August 31, 2026.');
|
||
expect(html).toContain('Abyssinia Trading PLC');
|
||
expect(html).toContain('Annex A — Commercial Schedule');
|
||
// Greenish theme marker from styles.hbs
|
||
expect(html).toContain('#1b9e7a');
|
||
});
|
||
|
||
it('shows the contract validity window in the commercial schedule annex', () => {
|
||
const html = renderer.render(dynamicView());
|
||
expect(html).toContain('Valid from');
|
||
expect(html).toContain('Valid until');
|
||
expect(html).toContain('1 January 2026');
|
||
expect(html).toContain('31 December 2026');
|
||
});
|
||
|
||
it('interpolates the start/end date placeholders inside article text', () => {
|
||
const view = dynamicView();
|
||
expect(
|
||
interpolateTemplateText(
|
||
'In force {{contractStartDate}} to {{contractEndDate}}.',
|
||
view,
|
||
),
|
||
).toBe('In force 1 January 2026 to 31 December 2026.');
|
||
});
|
||
|
||
it('shows cargo type and container type in the commercial schedule annex', () => {
|
||
const html = renderer.render(dynamicView());
|
||
expect(html).toContain('Cargo type');
|
||
expect(html).toContain('Container type');
|
||
expect(html).toContain('Cargo scope');
|
||
expect(html).toContain('Steel billets × 2,800');
|
||
});
|
||
|
||
it('interpolates the cargo/container placeholders inside article text', () => {
|
||
const view = dynamicView();
|
||
const body =
|
||
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
|
||
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
|
||
expect(interpolateTemplateText(body, view)).toBe(
|
||
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
|
||
);
|
||
});
|
||
|
||
it('renders the live rate schedule lane under the pricing article', () => {
|
||
const html = renderer.render(dynamicView());
|
||
expect(html).toContain('Rate Schedule');
|
||
// Base freight lane pulled from the rate config
|
||
expect(html).toContain('Nagad → Galaan Multipurpose Port');
|
||
expect(html).toContain('USD 100 per wagon');
|
||
// Additional-service group
|
||
expect(html).toContain('First-mile pickup by truck');
|
||
});
|
||
|
||
it('keeps the generic layout when no dynamic template is attached', () => {
|
||
const view = dynamicView();
|
||
delete view.dynamicTemplate;
|
||
view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING');
|
||
const html = renderer.render(view);
|
||
expect(html).toContain('Article 5: Contract Price');
|
||
});
|
||
});
|