diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts index 6aacab16c..92bef9dd3 100644 --- a/apps/edr-freight-api/src/contracts/contract-article.util.ts +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -13,6 +13,9 @@ export interface RenderedClause { /** A dynamic article ready for the Handlebars template. */ export interface RenderedArticle { number: number; + /** Stable article id from the template (e.g. "pricing") — lets the layout + * inject the live rate schedule table under the pricing article. */ + id: string; title: string; /** Set (instead of clauses) when the body is a single plain paragraph. */ paragraph?: string; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index a298b8dcb..363008660 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -18,6 +18,7 @@ import { ContractDynamicTemplateView, ContractViewModel, } from './contract-view-model.builder'; +import { RateSchedule } from './contract-rate-schedule.builder'; /** * Signature row for the contract PDF. Mirrors the booking builder's @@ -135,6 +136,7 @@ export class ContractDocumentViewModelBuilder { } const pricing = this.buildPricing(contract); + const rateSchedule = this.buildRateSchedule(pricing); const signatures = await this.loadSignatures(contractId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -177,6 +179,7 @@ export class ContractDocumentViewModelBuilder { }, schedule: this.buildSchedule(contract), pricing: pricing as unknown as ContractViewModel['pricing'], + rateSchedule, // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], @@ -230,6 +233,40 @@ export class ContractDocumentViewModelBuilder { }; } + /** + * A rate schedule for the contract PDF, sourced from the contract's own frozen + * unit rates (its agreed lane prices) rather than the global rate config — a + * signed contract must show the prices it was signed on. Rendered as freight + * lanes labelled with the contract's primary origin → destination route. + */ + private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule { + const route = `${pricing.originLabel} → ${pricing.destinationLabel}`; + const freightLanes = pricing.unitRates.map((line) => ({ + route, + cargo: line.label, + currency: line.currency, + amount: this.formatAmount(line.unitPrice), + unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`, + })); + + return { + freightLanes, + additionalServices: [], + surcharges: [], + isEmpty: freightLanes.length === 0, + currencyLabel: pricing.currency, + }; + } + + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { const firstRoute = this.firstRoute(contract); const cargoScope = (contract.cargoScope ?? [])[0]; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts index 49fb3416a..765c41142 100644 --- a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { 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, @@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { 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: 2, + order: 3, }, ], }, @@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => { expect(html).toContain('#1b9e7a'); }); + 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; diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts new file mode 100644 index 000000000..af9b9428c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@nestjs/common'; + +import { RatesService } from '../modules/rule-engine/services/rates.service'; +import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { + ContractDirection, + ContractFreight, +} from './contract-template.types'; + +/** One priced line in the contract's rate schedule. */ +export interface RateScheduleRow { + /** "Negad → Mojo Dry Port" for base freight, service name otherwise. */ + route: string; + /** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */ + cargo: string; + currency: string; + /** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */ + amount: string; + /** Human unit, e.g. "per container", "per wagon", "per ton". */ + unit: string; +} + +/** + * The origin → destination rate schedule shown in a generated contract's + * pricing article. Grouped so the reader sees rail freight lanes first, then + * pickup/delivery legs, then trigger-based surcharges and demurrage. + */ +export interface RateSchedule { + /** Base rail freight lanes matching this contract's direction + freight. */ + freightLanes: RateScheduleRow[]; + /** First-mile / last-mile truck legs (route-agnostic). */ + additionalServices: RateScheduleRow[]; + /** Hazard, reefer, overweight, demurrage, customs, etc. */ + surcharges: RateScheduleRow[]; + /** True when every group is empty — the template falls back to prose. */ + isEmpty: boolean; + /** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */ + currencyLabel: string; +} + +const UNIT_LABELS: Record = { + PER_WAGON: 'per wagon', + PER_TON: 'per ton', + PER_CONTAINER: 'per container', + PER_KM: 'per km', + PER_INVOICE: 'per invoice', + FLAT: 'flat', +}; + +const SERVICE_ROUTE_LABELS: Partial> = { + FIRST_MILE: 'First-mile pickup by truck', + LAST_MILE: 'Last-mile delivery by truck', +}; + +/** Friendly wording for the trigger-based charges shown in the surcharge group. */ +const TRIGGER_ROUTE_LABELS: Partial> = { + HAZARDOUS: 'Hazardous cargo surcharge', + OVERWEIGHT: 'Overweight surcharge', + REEFER: 'Reefer (refrigerated) surcharge', + WITH_RETURN: 'Empty-container return service', + SHIPPING_LINE: 'Shipping line handling', + CONSOLIDATION: 'Container consolidation (extra document)', + LASHING: 'Cargo lashing and securing', + CANCELLATION: 'Booking cancellation fee', + DEMURRAGE: 'Demurrage / wagon detention', + PIL_EXTRA_FEE: 'PIL shipping line extra fee', + CUSTOMS_CLEARANCE: 'Customs clearance service', +}; + +@Injectable() +export class ContractRateScheduleBuilder { + constructor(private readonly ratesService: RatesService) {} + + /** + * Build the rate schedule for a contract of the given direction + freight. + * Base-freight lanes are filtered to the matching trade direction / freight + * kind so an import container contract shows import container lanes only; + * additional services and surcharges are route-agnostic and always shown. + */ + async build( + direction: ContractDirection, + freight: ContractFreight, + ): Promise { + const rates = await this.ratesService.findLiveRatesDetailed(); + + const freightLanes: RateScheduleRow[] = []; + const additionalServices: RateScheduleRow[] = []; + const surcharges: RateScheduleRow[] = []; + + for (const rate of rates) { + if (this.isBaseFreight(rate)) { + if (this.baseFreightMatches(rate, direction, freight)) { + freightLanes.push(this.laneRow(rate)); + } + continue; + } + + if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') { + additionalServices.push(this.serviceRow(rate)); + continue; + } + + // Everything left is a trigger-based charge (surcharge / demurrage / customs). + surcharges.push(this.surchargeRow(rate)); + } + + const currencyLabel = this.currencyLabel([ + ...freightLanes, + ...additionalServices, + ...surcharges, + ]); + + return { + freightLanes, + additionalServices, + surcharges, + isEmpty: + freightLanes.length === 0 && + additionalServices.length === 0 && + surcharges.length === 0, + currencyLabel, + }; + } + + private isBaseFreight(rate: Rate): boolean { + return ( + rate.trigger === 'ALWAYS' && + (rate.appliesTo === 'BULK' || + rate.appliesTo === 'CONTAINER' || + rate.appliesTo === 'INTERCITY') + ); + } + + private baseFreightMatches( + rate: Rate, + direction: ContractDirection, + freight: ContractFreight, + ): boolean { + // Domestic contracts price off intercity rates; the freight kind is carried + // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). + if (direction === 'DOM') { + if (rate.appliesTo !== 'INTERCITY') return false; + return freight === 'BULK' + ? rate.rateType === 'INTERCITY_BULK' + : rate.rateType === 'INTERCITY_CONTAINER'; + } + + // Import / export price off BULK or CONTAINER rates matching the direction. + const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER'; + if (rate.appliesTo !== wantAppliesTo) return false; + const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT'; + return rate.tradeDirection === wantDirection; + } + + private laneRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + return { + route: `${origin} → ${destination}`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private serviceRow(rate: Rate): RateScheduleRow { + return { + route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + private surchargeRow(rate: Rate): RateScheduleRow { + return { + route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + + /** The type a rate is scoped to (container/cargo), or a dash when unscoped. */ + private cargoLabel(rate: Rate): string { + return ( + rate.containerType?.label ?? + rate.containerType?.code ?? + rate.cargoType?.cargoTypeName ?? + '—' + ); + } + + private unitLabel(unit: Rate['rateUnit']): string { + return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' '); + } + + /** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */ + private formatAmount(value: number | string): string { + const num = Number(value); + if (!Number.isFinite(num)) return String(value); + return num.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + } + + private currencyLabel(rows: RateScheduleRow[]): string { + const seen: string[] = []; + for (const row of rows) { + if (!seen.includes(row.currency)) seen.push(row.currency); + } + return seen.join(', ') || 'USD'; + } + + private titleCase(value: string): string { + return value + .toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts index a66a516c0..b0fc4c8ef 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -58,6 +58,15 @@ describe('ContractRendererService', () => { destinationLabel: 'Modjo', containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], }, + rateSchedule: { + freightLanes: [ + { route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' }, + ], + additionalServices: [], + surcharges: [], + isEmpty: false, + currencyLabel: 'USD', + }, signatures: [], canSignCustomer: true, canSignStaff: false, diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index 7b3301c87..b1b02c62f 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit { .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((article, index) => ({ number: index + 1, + id: article.id, title: interpolateTemplateText(article.title, view), ...parseArticleBody(interpolateTemplateText(article.body, view)), })); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index d90b8e709..8b8b92f09 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -7,6 +7,7 @@ import { ContractSignerRole, } from '../modules/bookings/entities/booking-contract-signature.entity'; import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; @@ -74,6 +75,12 @@ export interface ContractViewModel { lastMileDeliveryAddress: string; }; pricing: PricingSchedule; + /** + * The live origin → destination rate schedule (base freight lanes + services + * + surcharges) matching this contract's direction and freight kind. Drives + * the pricing article's rate table so the contract mirrors the rate config. + */ + rateSchedule: RateSchedule; signatures: ContractSignatureView[]; canSignCustomer: boolean; canSignStaff: boolean; @@ -89,6 +96,7 @@ export class ContractViewModelBuilder { private readonly bookingsRepository: BookingsRepository, private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, + private readonly rateScheduleBuilder: ContractRateScheduleBuilder, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -101,6 +109,10 @@ export class ContractViewModelBuilder { booking.contractTemplateKey ?? this.templateResolver.resolve(booking); const template = getTemplateMeta(templateKey); const pricing = await this.pricingBuilder.build(booking); + const rateSchedule = await this.rateScheduleBuilder.build( + template.direction, + template.freight, + ); const signatures = await this.loadSignatures(bookingId); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); @@ -143,6 +155,7 @@ export class ContractViewModelBuilder { }, schedule: this.buildSchedule(booking), pricing, + rateSchedule, signatures, canSignCustomer: booking.status === 'CONTRACT_READY' && !hasCustomer, diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index 64319612a..eec54fa79 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,25 +25,9 @@

Equipment return: {{pricing.equipmentReturn}}

{{/if}} - {{#if pricing.unitRates}} -

Unit Rate Schedule

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting - totals are determined per shipment at booking time; no total contract value is fixed at this stage. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{#unless rateSchedule.isEmpty}} +

Rate Schedule

+ {{> rate_schedule}} {{else}}

Charges

@@ -76,7 +60,7 @@
- {{/if}} + {{/unless}}

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs index bec620655..4bdc9a24a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -20,5 +20,9 @@ {{/each}} {{/if}} + {{#if (eq id "pricing")}} +

Rate Schedule

+ {{> rate_schedule}} + {{/if}} {{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs new file mode 100644 index 000000000..f0cb0fa7d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/rate_schedule.hbs @@ -0,0 +1,55 @@ +{{#if rateSchedule.isEmpty}} +

+ No published rate schedule is currently on file for this corridor. Applicable charges will be quoted + by the Service Provider per shipment in accordance with the prevailing EDR tariff. +

+{{else}} +

+ The charges below are the current published railway tariff for this contract's trade direction and + freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting + totals are determined per shipment at booking time. +

+ + + + + + + + + + {{#if rateSchedule.freightLanes.length}} + + {{#each rateSchedule.freightLanes}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.additionalServices.length}} + + {{#each rateSchedule.additionalServices}} + + + + + + {{/each}} + {{/if}} + + {{#if rateSchedule.surcharges.length}} + + {{#each rateSchedule.surcharges}} + + + + + + {{/each}} + {{/if}} + +
Route / ServiceCargo / EquipmentUnit price
Railway Freight — Origin → Destination
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Additional Services
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
Surcharges, Demurrage & Fees
{{route}}{{cargo}}{{currency}} {{amount}} {{unit}}
+{{/if}} diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 4ba35ea3b..0e06d9f7b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -134,26 +134,8 @@ - {{#if pricing.unitRates.length}} -

Agreed Unit Rates

-

- The rates below are the frozen unit prices applicable to this contract. Quantities and resulting - totals are determined per shipment at booking time. -

- - - - - - {{#each pricing.unitRates}} - - - - - {{/each}} - -
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
- {{/if}} +

Published Rate Schedule

+ {{> rate_schedule}} {{!-- ────────────────────────── Signatures ───────────────────────────── --}} diff --git a/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts new file mode 100644 index 000000000..b435e55ea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2330000000000-AddBookingCloseOffset.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a global "booking close offset" — how long BEFORE departure a schedule's + * booking window shuts — configurable separately for import and export. + * + * When an offset is set, the window's close instant is `departure − offset` + * (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure + * Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole + * booking lifecycle: the first window close, every reopen cycle, and the export + * FCFS close all land at/at-or-before this cutoff instead of at departure. + * + * NULL / 0 preserves the previous behaviour exactly (import closes at + * open+duration clamped to departure; export closes at departure), so existing + * installs are unaffected until an offset is entered. + * + * `*_close_offset_minutes` on the global-rules singleton is the live config; the + * matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown + * even after a later global-rules edit. Both are nullable with no backfill — + * absent means "no offset", the safe default. + */ +export class AddBookingCloseOffset2330000000000 implements MigrationInterface { + name = "AddBookingCloseOffset2330000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer, + ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_import_close_offset_minutes, + DROP COLUMN IF EXISTS rule_export_close_offset_minutes; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_close_offset_minutes, + DROP COLUMN IF EXISTS export_close_offset_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts new file mode 100644 index 000000000..6585cc842 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddCargoTypeHasLashing.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add `has_lashing` to cargo types. + * + * When true, every booking of that cargo type incurs the flat LASHING + * surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing + * cargo ships without the fee until the flag is turned on. + */ +export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface { + name = "AddCargoTypeHasLashing2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS has_lashing; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts new file mode 100644 index 000000000..ad389e929 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2340000000000-AddReverseWagonOrder.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add an opt-in "reverse wagon order" flag to a train schedule. + * + * When true, the built wagon plan is flipped at build time so the physically-last + * wagon sits at position 1. Only the order (sequence_no) changes — composition and + * booking allocations travel with their slot. The flag is frozen on the schedule + * at creation and re-applied every time the wagon plan is rebuilt, so the stored + * train order and the schedule order always match. + * + * Defaults to false; existing schedules keep their as-built order. + */ +export class AddReverseWagonOrder2340000000000 implements MigrationInterface { + name = "AddReverseWagonOrder2340000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS reverse_wagon_order; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..95e007c8a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2350000000000-RefreshContractPricingArticles.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Refresh the "pricing" article of each seeded contract template so it points + * at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon, + * USD 919/40ft, …). The original CreateContractTemplates migration seeded the + * old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB + * rows and would otherwise contradict the rate-config-driven schedule table now + * rendered under the pricing article. + * + * Only the article whose id = 'pricing' is touched, and only when its body + * still matches the originally-seeded prose — so any admin edit to the pricing + * article is left untouched. Idempotent: re-running is a no-op once refreshed. + */ +export class RefreshContractPricingArticles2350000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const pricing = seed.articles.find((article) => article.id === 'pricing'); + if (!pricing) continue; + + // jsonb_set the title + body of the element whose id = 'pricing', matched + // by array index. Guarded so admin-edited bodies are never overwritten. + await queryRunner.query( + ` + UPDATE freight.contract_templates ct + SET articles = ( + SELECT jsonb_agg( + CASE + WHEN elem->>'id' = 'pricing' + THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text) + ELSE elem + END + ) + FROM jsonb_array_elements(ct.articles) elem + ) + WHERE ct.code = $1 + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(ct.articles) e + WHERE e->>'id' = 'pricing' + AND e->>'body' LIKE ANY (ARRAY[ + '%USD 59.4 per metric ton%', + '%USD 696 (six hundred ninety-six) per wagon%', + '%USD 400 (four hundred) per wagon%', + '%From SGTD to Dire Dawa dry port, the rate is USD 919%', + '%Railway transportation charges from GMP to SGTD: USD 819%', + '%prevailing EDR domestic container tariff, as set out in the commercial schedule%' + ]) + ); + `, + [seed.code, pricing.title, pricing.body], + ); + } + } + + public async down(): Promise { + // No-op: the refreshed pricing prose is the correct forward state; reverting + // to hardcoded figures would reintroduce the rate-schedule contradiction. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index db6b70eae..3f4f64186 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity'; const MOCK_CBE_RATE = 130; +// Base freight is configured per leg, so every rate and every booking names the +// route it runs. MOJO → DIRE is the corridor these rates are priced for. +const MOJO = 'yard-mojo'; +const DIRE = 'yard-dire-dawa'; +const LEBU = 'yard-lebu'; + describe('BookingPricingService — domestic corridor', () => { const intercityBulkUsd: Rate = { id: 'rate-intercity-bulk-usd', @@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_TON', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; const intercityContainerUsd: Rate = { @@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => { rateUnit: 'PER_CONTAINER', status: 'LIVE', containerTypeId: null, + originYardId: MOJO, + destinationYardId: DIRE, } as Rate; let service: BookingPricingService; @@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'USD', cargoTotalWeightVgm: 120, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => { tradeDirection: 'DOMESTIC', paymentCurrency: 'ETB', cargoTotalWeightVgm: 50, + originYardId: MOJO, + destinationYardId: DIRE, bookingContainers: [], } as unknown as Booking; @@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => { const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; expect(line.currency).toBe('ETB'); }); + + // Rates are quoted per leg, so one configured for MOJO → DIRE must not price a + // shipment that runs LEBU → DIRE. Charging the wrong corridor's price because + // nobody configured this one yet is worse than billing no base freight. + it('does not price bulk off a rate configured for a different leg', async () => { + const booking = { + id: 'b-3', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(0); + }); + + it('does not price containers off a rate configured for a different leg', async () => { + const booking = { + id: 'b-4', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 50, + originYardId: LEBU, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ amount: number }> }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: 'ct-20', quantity: 3 }], + }); + + expect(result.lineItems).toHaveLength(0); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 9201e4fa9..cbbb999ef 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** @@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => { ); }); }); + +/** + * Export over-book gate at the customer's requestOperation step: export never + * splits, so the free-space check runs the moment the customer commits to a + * shipment day. When no single export train that day can carry the whole + * booking, `pickExportSchedule` throws and the request is refused BEFORE the + * booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated + * here (they are batched + splittable later). + */ +describe('BookingTransitionService — requestOperation export space gate', () => { + function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: 'CLEARANCE_READY', + tradeDirection, + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest + .fn() + .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), + }; + const bookingBatchService = { + // Over-book → the export gate rejects; otherwise it returns a schedule id. + pickExportSchedule: overbook + ? jest.fn().mockRejectedValue(new ConflictException('Not enough train space')) + : jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRequestedToStaff: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + ); + return { service, bookingsRepository, bookingBatchService }; + } + + it('rejects an over-booked export request and does NOT advance the booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + true, + ); + await expect( + service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('lets an export request through when a train fits the whole booking', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'EXPORT', + false, + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); + + it('never runs the export gate for an import request', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService( + 'IMPORT', + true, // would reject IF called — proves it is not called + ); + await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); + expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled(); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2aed86027..690b66385 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1036,6 +1036,22 @@ export class BookingTransitionService { ); } + // Export is FCFS and never splits — a booking must ride one train whole. So + // the free-space check belongs HERE, the moment the customer commits to a + // shipment day, not later at staff operation-accept. Blocking now stops the + // customer booking more wagons than any single export train that day can + // still carry; `exportSpaceReport` throws a 409 whose message carries the + // largest bookable leftover ("reduce to N wagons or pick another day"). + // Import/domestic bookings are batched + splittable, so they are NOT gated + // here — they get an advisory count below and the batch engine sizes them. + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + if (isExportTrain) { + await this.bookingBatchService.pickExportSchedule(scheduledBooking); + } + await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, @@ -1045,6 +1061,47 @@ export class BookingTransitionService { return fresh; } + /** + * Advisory availability for a shipment day the customer is considering — a + * planning hint for the day picker, computed but never enforced. For EXPORT it + * mirrors the real request-time gate: `fits` is whether a single open train + * that day can carry the WHOLE booking (export never splits), and `freeWagons` + * is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the + * TOTAL room across the day's trains for the booking's wagon type (the batch + * engine may still split or defer a remainder), and `fits` is whether that + * total covers the booking. `trainsForDay` is false when no departure carries + * the leg — the day is unbookable regardless of space. + */ + async dayAvailabilityForBooking( + bookingId: string, + scheduledDate: string, + ): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + const day = eatDay(date); + const isExportTrain = + booking.tradeDirection === "EXPORT" && + !isRoadService(booking.serviceType); + + if (isExportTrain) { + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + const report = + await this.bookingBatchService.exportSpaceReport(scheduledBooking); + return { + fits: report.scheduleId != null, + freeWagons: report.bestAvailable?.wagons ?? 0, + trainsForDay: report.trainsForDay && report.corridorMatched, + }; + } + + const { freeWagons, need, trainsForDay } = + await this.bookingBatchService.dayImportAvailability(booking, day); + return { fits: freeWagons >= need, freeWagons, trainsForDay }; + } + /** * Operations team reviews a pending operation request (capacity, documents, * route). Two outcomes: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 53554b2e3..56e788b93 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -370,6 +370,31 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } + @Get(':id/day-availability') + @ApiOperation({ + summary: + 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + + 'Export: whole-booking fit + largest single-train leftover. ' + + 'Import/domestic: total room across the day for the booking\'s wagon type.', + }) + async dayAvailability( + @Param('id', ParseUUIDPipe) id: string, + @Query('date') date: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.dayAvailabilityForBooking(id, date); + } + @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e7806c13c..15c5e751d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; @@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, + ContractRateScheduleBuilder, ContractRendererService, ContractPdfService, CustomerTruckAssignmentsRepository, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2aea9bc7..d2755fece 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { RateSchedule } from "../../contracts/contract-rate-schedule.builder"; import { getTemplateMeta } from "../../contracts/contract-template.registry"; import { ContractDynamicTemplateView, @@ -177,17 +178,9 @@ export class ContractTemplatesService { const isBulk = code.endsWith("_BULK"); const now = new Date(); - const unitRates = isBulk - ? [ - { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, - { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, - { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, - ] - : [ - { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, - { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, - { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, - ]; + // Representative rate schedule so the admin preview shows the live-rate + // table shape. Real contracts populate this from freight.rates (LIVE). + const rateSchedule = this.mockRateSchedule(code, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -239,13 +232,16 @@ export class ContractTemplatesService { lastMileDeliveryAddress: "—", }, pricing: { - displayMode: "UNIT_RATES", - unitRates, + lineItems: [], + surcharges: [], + totalAmount: 0, currency: "USD", equipmentReturn: isBulk ? "—" : "With empty return", originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", + containerLines: [], } as unknown as ContractViewModel["pricing"], + rateSchedule, signatures: [], canSignCustomer: false, canSignStaff: false, @@ -256,6 +252,43 @@ export class ContractTemplatesService { }; } + /** Static, representative rate schedule for the admin preview only. */ + private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + const dir = code.startsWith("IMPORT") + ? "import" + : code.startsWith("EXPORT") + ? "export" + : "domestic"; + const lane = + dir === "export" + ? "Galaan Multipurpose Port → SGTD" + : dir === "domestic" + ? "Mojo Dry Port → Dire Dawa" + : "Negad → Mojo Dry Port"; + + const freightLanes = isBulk + ? [ + { route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" }, + ] + : [ + { route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" }, + { route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" }, + ]; + + return { + freightLanes, + additionalServices: [ + { route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + { route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" }, + ], + surcharges: [ + { route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" }, + ], + isEmpty: false, + currencyLabel: "USD", + }; + } + private assertCode(code: string): ContractTemplateCode { const upper = code?.toUpperCase() as ContractTemplateCode; if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index c2c034c18..6f7882c76 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -37,6 +37,14 @@ export class CreateCargoTypeDto { @IsBoolean() requiresDirectorApproval?: boolean; + @ApiPropertyOptional({ + default: false, + description: 'When true, bookings of this cargo type incur the flat LASHING surcharge.', + }) + @IsOptional() + @IsBoolean() + hasLashing?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 7396595d1..037bf08be 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -53,6 +53,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; + /** + * When true, any booking of this cargo type incurs the flat LASHING surcharge + * (the LASHING-trigger rate). Set on commodities that need EDR-provided + * lashing/securing; leave false for cargo that ships without it. + */ + @Column({ name: 'has_lashing', type: 'boolean', default: false }) + hasLashing!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 5ecc006c1..09f35c458 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -30,6 +30,7 @@ export function deriveRateType(input: { case 'SHIPPING_LINE': return 'DOUBLE_HANDLING'; case 'CONSOLIDATION': + case 'LASHING': return 'LASHING'; case 'CANCELLATION': return 'CANCELLATION_FEE'; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 9bbd7728d..78e2eb724 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -36,6 +36,9 @@ export function allowedRateUnits(input: { case 'CUSTOMS_CLEARANCE': // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). return ['FLAT']; + case 'LASHING': + // Flat cargo-securing fee, billed once per booking. + return ['FLAT']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 6f7ac78ee..cd2a6e14b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -78,6 +78,9 @@ export const RATE_TRIGGERS = [ 'WITH_RETURN', 'SHIPPING_LINE', 'CONSOLIDATION', + // Cargo securing / lashing. Fires when the booking's cargo type has + // hasLashing = true. Flat fee, billed once per booking. + 'LASHING', 'CANCELLATION', 'DEMURRAGE', 'PIL_EXTRA_FEE', diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index a23e47eea..76203edef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -6,6 +6,12 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + /** + * LIVE rates with the yard / container / cargo relations eagerly joined, so + * lanes can be rendered with human labels (contract rate schedule). Ordered + * for a stable, readable schedule table. + */ + findLiveRatesDetailed(): Promise; findByPattern(pattern: { rateType: string; rateUnit: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index d855fb491..48a948784 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -25,6 +25,22 @@ export class RatesRepository implements IRatesRepository { .getMany(); } + findLiveRatesDetailed(): Promise { + return this.repo + .createQueryBuilder('rate') + .leftJoinAndSelect('rate.originYard', 'originYard') + .leftJoinAndSelect('rate.destinationYard', 'destinationYard') + .leftJoinAndSelect('rate.containerType', 'containerType') + .leftJoinAndSelect('rate.cargoType', 'cargoType') + .where('rate.status = :status', { status: 'LIVE' }) + .orderBy('rate.appliesTo', 'ASC') + .addOrderBy('rate.tradeDirection', 'ASC') + .addOrderBy('originYard.label', 'ASC') + .addOrderBy('destinationYard.label', 'ASC') + .addOrderBy('rate.rateValue', 'ASC') + .getMany(); + } + /** * Find a non-superseded rate matching an identity pattern — the same tuple the * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index bc1a8095d..03be1b3d0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -61,6 +61,12 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * Booking's cargo type needs EDR-provided lashing/securing (cargoType + * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the + * engine from cargoTypeId when omitted. + */ + hasLashing?: boolean; totalWagons: number; /** * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale @@ -132,12 +138,22 @@ export class RuleEngineService { requiresDirectorApproval = true; } + // Lashing is a cargo-type property: a booking incurs the flat LASHING + // surcharge when its cargo type has hasLashing = true. Resolve it here so + // matchesTrigger can fire the LASHING rate. Falls back to an explicit + // input flag when no cargo type is set (e.g. container bookings). + let hasLashing = input.hasLashing === true; if (input.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); if (!cargoType) { hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); - } else if (cargoType.requiresDirectorApproval) { - requiresDirectorApproval = true; + } else { + if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + if (cargoType.hasLashing) { + hasLashing = true; + } } } @@ -237,6 +253,7 @@ export class RuleEngineService { hasOverweight, shippingLineMapped, allowConsolidation: input.allowConsolidation ?? false, + hasLashing, }); if (!triggered) continue; @@ -466,6 +483,7 @@ export class RuleEngineService { hasOverweight: boolean; shippingLineMapped: boolean; allowConsolidation: boolean; + hasLashing: boolean; }, ): boolean { // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. @@ -484,6 +502,8 @@ export class RuleEngineService { return truthy(state.shippingLineMapped); case 'CONSOLIDATION': return truthy(state.allowConsolidation); + case 'LASHING': + return truthy(state.hasLashing); // CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied // explicitly elsewhere (not auto-triggered by a booking's cargo flags). default: diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c429e3d45..edd9ac9f0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -44,6 +44,14 @@ export class RatesService { return this.repository.findLiveRates(); } + /** + * LIVE rates with yard / container / cargo relations joined — used to render + * the origin → destination rate schedule inside generated contracts. + */ + async findLiveRatesDetailed(): Promise { + return this.repository.findLiveRatesDetailed(); + } + /** Get a rate by ID. */ async findById(id: string): Promise { const entity = await this.repository.findById(id); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index cf87622ab..2b9968f04 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -73,6 +73,16 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; + /** + * Reverse the wagon ORDER on this train: when true, the built wagon plan is + * flipped at build so the physically-last wagon sits at position 1. Only the + * order (sequenceNo) changes — composition and allocations travel with their + * slot. Frozen at create; every (re)assignment rebuilds under this flag so the + * stored train order and the schedule order always match. Default false. + */ + @Column({ name: 'reverse_wagon_order', type: 'boolean', default: false }) + reverseWagonOrder!: boolean; + @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) actualDepartureAt?: Date | null; @@ -149,6 +159,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) ruleExportBookingLeadHours?: number | null; + /** Frozen import booking-close offset (minutes before departure). NULL = none. */ + @Column({ name: 'rule_import_close_offset_minutes', type: 'int', nullable: true }) + ruleImportCloseOffsetMinutes?: number | null; + + /** Frozen export booking-close offset (minutes before departure). NULL = none. */ + @Column({ name: 'rule_export_close_offset_minutes', type: 'int', nullable: true }) + ruleExportCloseOffsetMinutes?: number | null; + // Frozen wagon plan captured once when the schedule leaves the editable // DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a // non-editable schedule read THIS instead of the live wagon↔slot joins, so the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e913af6b7..51669459c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -6,6 +6,8 @@ import { listConfigBookingWindows, groupBookingsIntoBoardWindows, computeImportWindowTimes, + computeExportWindowTimes, + bookingCloseCutoff, type BoardWindowConfig, } from './batch-window.util'; @@ -360,3 +362,101 @@ describe('computeImportWindowTimes — immediate open inside the window day', () expect(t.windowOpensAt.getTime()).toBe(now.getTime()); }); }); + +// Booking-close offset: a configured offset pulls the window close earlier than +// departure by that many minutes, separately for import and export. +describe('bookingCloseCutoff — departure − offset', () => { + const departure = new Date('2026-07-10T13:00:00.000Z'); // 16:00 EAT Jul 10 + + it('returns departure unchanged when no offset is set', () => { + expect(bookingCloseCutoff(departure, 'IMPORT', {}).toISOString()).toBe( + departure.toISOString(), + ); + expect( + bookingCloseCutoff(departure, 'EXPORT', { + importCloseOffsetMinutes: 180, + }).toISOString(), + ).toBe(departure.toISOString()); + }); + + it('a non-positive offset is treated as no offset', () => { + expect( + bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: 0, + }).toISOString(), + ).toBe(departure.toISOString()); + expect( + bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: -5, + }).toISOString(), + ).toBe(departure.toISOString()); + }); + + it('import 3-hour offset: 16:00 EAT departure → cutoff 13:00 EAT (14:00 → 3h before)', () => { + // Departure 16:00 EAT (13:00 UTC), 3h offset → 13:00 EAT = 10:00 UTC. + const cutoff = bookingCloseCutoff(departure, 'IMPORT', { + importCloseOffsetMinutes: 180, + }); + expect(cutoff.toISOString()).toBe('2026-07-10T10:00:00.000Z'); + }); + + it('export 1-day offset: Jul-10 16:00 EAT departure → cutoff Jul-9 16:00 EAT', () => { + const cutoff = bookingCloseCutoff(departure, 'EXPORT', { + exportCloseOffsetMinutes: 1440, + }); + // Jul 9 16:00 EAT = Jul 9 13:00 UTC. + expect(cutoff.toISOString()).toBe('2026-07-09T13:00:00.000Z'); + }); + + it('import and export offsets are independent', () => { + const cfg = { + importCloseOffsetMinutes: 180, + exportCloseOffsetMinutes: 1440, + }; + expect(bookingCloseCutoff(departure, 'IMPORT', cfg).toISOString()).toBe( + '2026-07-10T10:00:00.000Z', + ); + expect(bookingCloseCutoff(departure, 'EXPORT', cfg).toISOString()).toBe( + '2026-07-09T13:00:00.000Z', + ); + // DOMESTIC uses the import offset. + expect(bookingCloseCutoff(departure, 'DOMESTIC', cfg).toISOString()).toBe( + '2026-07-10T10:00:00.000Z', + ); + }); +}); + +describe('window-time computation honours the close offset', () => { + it('export closes at departure − offset, not departure', () => { + // Departs Jul 10 16:00 EAT (13:00 UTC), lead 24h, 24-hour desk, 1-day offset. + const departure = new Date('2026-07-10T13:00:00.000Z'); + const { windowClosesAt } = computeExportWindowTimes(departure, { + exportBookingLeadHours: 48, + windowOpenHour: 8, + windowCloseHour: 8, // 24-hour desk + exportCloseOffsetMinutes: 1440, + }); + // Jul 9 16:00 EAT = Jul 9 13:00 UTC. + expect(windowClosesAt.toISOString()).toBe('2026-07-09T13:00:00.000Z'); + }); + + it('import close is capped at the cutoff (departure − offset)', () => { + // Round-the-clock desk, opens 05 Jul 12:00 EAT, 24h duration would run to + // 06 Jul 12:00; departure 06 Jul 08:00 EAT (05:00 UTC) with a 2-hour offset → + // cutoff 06 Jul 06:00 EAT = 03:00 UTC. + const departure = new Date('2026-07-06T05:00:00.000Z'); + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes( + departure, + { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 8, // 24-hour desk (no office-hour cap) + windowDurationHours: 24, + importCloseOffsetMinutes: 120, + }, + now, + ); + expect(windowClosesAt.toISOString()).toBe('2026-07-06T03:00:00.000Z'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index a1dfe61f4..aa0540544 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -266,6 +266,29 @@ export function clampCloseToOfficeHours( return closesAt; } +/** + * The instant a schedule stops accepting bookings. By default that is departure, + * but a configured close offset (import/export, minutes) pulls it earlier: + * `departure − offset`. This is the single bound every window close, reopen + * cycle and export FCFS close is capped at — swap it in wherever the logic used + * to cap at departure. A non-positive/absent offset yields departure unchanged. + */ +export function bookingCloseCutoff( + departure: Date, + direction: string | null | undefined, + cfg: { + importCloseOffsetMinutes?: number | null; + exportCloseOffsetMinutes?: number | null; + }, +): Date { + const offsetMinutes = + direction === 'EXPORT' + ? cfg.exportCloseOffsetMinutes + : cfg.importCloseOffsetMinutes; + if (offsetMinutes == null || !(offsetMinutes > 0)) return departure; + return new Date(departure.getTime() - offsetMinutes * 60_000); +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; @@ -296,9 +319,13 @@ export function computeImportWindowTimes( windowOpenHour: number; windowCloseHour: number; windowDurationHours: number; + importCloseOffsetMinutes?: number | null; }, now: Date, ): InitialWindowTimes { + // The window opens off the REAL departure (open day = departure − leadDays), + // but shuts at the configured cutoff (departure − closeOffset, or departure). + const cutoff = bookingCloseCutoff(departure, 'IMPORT', cfg); const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour); @@ -324,8 +351,8 @@ export function computeImportWindowTimes( windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); - if (closesAt.getTime() > departure.getTime()) { - closesAt = departure; + if (closesAt.getTime() > cutoff.getTime()) { + closesAt = cutoff; } return { windowOpensAt: opensAt, windowClosesAt: closesAt }; } @@ -344,8 +371,12 @@ export function computeExportWindowTimes( exportBookingLeadHours: number; windowOpenHour: number; windowCloseHour: number; + exportCloseOffsetMinutes?: number | null; }, ): InitialWindowTimes { + // Opens off the real departure (lead hours), shuts at the cutoff + // (departure − closeOffset, or departure when no offset is set). + const cutoff = bookingCloseCutoff(departure, 'EXPORT', cfg); const rawOpen = new Date( departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, ); @@ -353,10 +384,12 @@ export function computeExportWindowTimes( windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }); - if (opensAt.getTime() > departure.getTime()) { - opensAt = departure; + // Open can't outlive the cutoff (a huge offset would otherwise leave a + // negative-length window); clamp to a zero-length window at the cutoff. + if (opensAt.getTime() > cutoff.getTime()) { + opensAt = cutoff; } - return { windowOpensAt: opensAt, windowClosesAt: departure }; + return { windowOpensAt: opensAt, windowClosesAt: cutoff }; } /** @@ -457,6 +490,10 @@ export interface BoardWindowConfig { */ reopenGapMinutes: number; exportBookingLeadHours: number; + /** Minutes before departure the import window shuts; NULL/0 ⇒ close at departure. */ + importCloseOffsetMinutes?: number | null; + /** Minutes before departure the export window shuts; NULL/0 ⇒ close at departure. */ + exportCloseOffsetMinutes?: number | null; } const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { @@ -507,10 +544,15 @@ export function listConfigBookingWindows( cfg: BoardWindowConfig, anchorOpensAt?: Date | null, ): BoardWindow[] { + // Bookings shut at the cutoff (departure − closeOffset), not departure. The + // window opens still key off the real departure below; only closes are capped + // here, so the board draws the exact windows the engine runs. + const cutoff = bookingCloseCutoff(departure, direction, cfg); + if (direction === 'EXPORT') { const start = anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; - return [boardWindowFromInterval(start, departure)]; + return [boardWindowFromInterval(start, cutoff)]; } const windows: BoardWindow[] = []; @@ -527,29 +569,29 @@ export function listConfigBookingWindows( let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); // The loop terminates naturally: every cycle advances opensAt by at least // (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would - // reach departure. maxCycles is a derived runaway backstop sized to the real - // span (first open → departure) over the smallest possible advance, so a + // reach the cutoff. maxCycles is a derived runaway backstop sized to the real + // span (first open → cutoff) over the smallest possible advance, so a // legitimate config is never silently truncated — only a pathological // zero-length one would hit it. - const spanMs = departure.getTime() - opensAt.getTime(); + const spanMs = cutoff.getTime() - opensAt.getTime(); const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000); const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2; for (let cycle = 0; cycle < maxCycles; cycle += 1) { - if (opensAt.getTime() >= departure.getTime()) break; + if (opensAt.getTime() >= cutoff.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours); - if (closesAt.getTime() > departure.getTime()) closesAt = departure; + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; windows.push(boardWindowFromInterval(opensAt, closesAt)); const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); - opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure); + opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, cutoff); if (opensAt == null) break; } - // Degenerate config (no window before departure) — surface a single window - // clamped to departure so the board still renders something meaningful. + // Degenerate config (no window before the cutoff) — surface a single window + // clamped to the cutoff so the board still renders something meaningful. if (windows.length === 0) { - windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + windows.push(boardWindowFromInterval(new Date(cutoff.getTime() - durationMs), cutoff)); } return windows; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts index cb2e63223..3df73b26a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts @@ -137,4 +137,109 @@ describe('BookingBatchService — exportSpaceReport (whole-booking, single train 'No export train is accepting bookings for this day', ); }); + + describe('dayImportAvailability (advisory, summed across the day)', () => { + const DAY_STR = '2026-07-20'; + + const importSchedule = (id: string, over: Record = {}) => ({ + id, + status: 'SCHEDULED', + direction: 'IMPORT', + scheduledDepartureDate: DAY, + bookingWindowStatus: 'OPEN', + windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase + ...over, + }); + + // A bulk booking small enough to fit; freeWagons is what matters, not `fits`. + const importBooking = (cargoTons: number) => + ({ + id: 'bk-imp', + freightType: 'BULK', + tradeDirection: 'IMPORT', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + }) as unknown as Booking; + + it('sums free wagons across every import train on the day', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + importSchedule('train-2'), + ]); + + const one = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + // Re-run with a single train to prove two trains sum to double one train. + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const solo = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(solo.freeWagons).toBeGreaterThan(0); + expect(one.freeWagons).toBe(solo.freeWagons * 2); + expect(one.trainsForDay).toBe(true); + }); + + it('ignores EXPORT trains — they are not part of the import pool', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + { ...importSchedule('train-2'), direction: 'EXPORT' }, + ]); + + const both = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const solo = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(both.freeWagons).toBe(solo.freeWagons); + }); + + it('ignores FULL trains', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1', { bookingWindowStatus: 'FULL' }), + ]); + + const report = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(report.freeWagons).toBe(0); + expect(report.trainsForDay).toBe(false); + }); + + it('nets out capacity already held by reserved bookings', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + importSchedule('train-1'), + ]); + const empty = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + bookingsRepository.findReservedForSchedule.mockResolvedValue([ + heavyReserved, + ]); + const withHold = await service.dayImportAvailability( + importBooking(60), + DAY_STR, + ); + + expect(withHold.freeWagons).toBeLessThan(empty.freeWagons); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 5beb920d3..866885074 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -731,6 +731,64 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, + * summed across every train on the booking's corridor that day. Unlike the + * export gate this does NOT block and does NOT first-fit a single train: + * import is batched and splittable, so the honest number a customer can plan + * against is the TOTAL room across the day's trains for the booking's wagon + * type, in that type's own wagon units. + * + * It deliberately skips the `isFillable` window-phase gate. A customer picks a + * shipment day while its window is still OPEN (or pre-window) — the batch fill + * only makes those trains fillable after the window closes — so gating on the + * fill phase here would report 0 for exactly the days customers are choosing. + * We therefore count any non-FULL train that carries the leg, netting out the + * capacity already consumed by allocated + live-reserved bookings + * (`remainingBudget`). The count is an upper bound: the batch engine may still + * split the booking across trains or defer a remainder to a later window. + */ + async dayImportAvailability( + booking: Booking, + day: string, + ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor.filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.bookingWindowStatus !== 'FULL' && + s.direction !== 'EXPORT', + ); + + const wagonDims = await this.loadWagonDims(); + const dims = this.dimsFor(booking, wagonDims); + const need = this.wagonsFor(booking, wagonDims); + let freeWagons = 0; + let trainsForDay = false; + + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + trainsForDay = true; + freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons; + } + + return { freeWagons, need, trainsForDay }; + } + /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready @@ -1118,6 +1176,17 @@ export class BookingBatchService implements OnModuleInit { s.ruleExportBookingLeadHours, liveCfg.exportBookingLeadHours, ), + // Frozen close offsets: a snapshot null means "no offset for this train" + // and stays null (not the live offset); only legacy rows lacking the + // column (undefined) fall back to live config. + importCloseOffsetMinutes: + s.ruleImportCloseOffsetMinutes !== undefined + ? s.ruleImportCloseOffsetMinutes + : liveCfg.importCloseOffsetMinutes, + exportCloseOffsetMinutes: + s.ruleExportCloseOffsetMinutes !== undefined + ? s.ruleExportCloseOffsetMinutes + : liveCfg.exportCloseOffsetMinutes, }; const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 7128bd705..617d83561 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -19,6 +19,17 @@ export interface BookingWindowConfig { /** Max staff document-review time after the window closes. */ docReviewMinutes: number; paymentWindowMinutes: number; + /** + * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set + * (> 0), the effective booking cutoff is `departure − this`, capping the first + * window close and every reopen cycle. NULL/0 ⇒ no offset (close at departure). + */ + importCloseOffsetMinutes?: number | null; + /** + * Minutes before departure the EXPORT FCFS booking window shuts. When set (> 0), + * export closes at `departure − this` instead of at departure. NULL/0 ⇒ none. + */ + exportCloseOffsetMinutes?: number | null; } /** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 69f64fdf4..02c5fb993 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -22,6 +22,7 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { + bookingCloseCutoff, clampCloseToOfficeHours, eatDay, nextCycleOpensAt, @@ -415,6 +416,15 @@ export class BookingWindowService implements OnModuleInit { // window and the cycle stays in PAYMENT; check live reservations on THIS // schedule because the day-level fill may have reserved onto a sibling. // Waiting bookings that fit no train stay pooled and the window reopens. + // Booking shuts at the configured cutoff (departure − closeOffset), not + // departure — every phase-end below is bounded by it, mirroring the initial + // window computation. + const cutoff = bookingCloseCutoff( + schedule.scheduledDepartureDate, + schedule.direction, + cfg, + ); + const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id); if ( promoted > 0 && @@ -423,8 +433,8 @@ export class BookingWindowService implements OnModuleInit { let paymentPhaseEndsAt = new Date( now.getTime() + cfg.paymentWindowMinutes * 60_000, ); - if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) { - paymentPhaseEndsAt = schedule.scheduledDepartureDate; + if (paymentPhaseEndsAt > cutoff) { + paymentPhaseEndsAt = cutoff; } await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); this.logger.log( @@ -441,11 +451,7 @@ export class BookingWindowService implements OnModuleInit { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, }; - const nextOpensAt = nextCycleOpensAt( - now, - officeHours, - schedule.scheduledDepartureDate, - ); + const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff); if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( @@ -464,8 +470,8 @@ export class BookingWindowService implements OnModuleInit { // Office hours end a running window early: never let the duration outlive // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00). nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours); - if (nextClosesAt > schedule.scheduledDepartureDate) { - nextClosesAt = schedule.scheduledDepartureDate; + if (nextClosesAt > cutoff) { + nextClosesAt = cutoff; } // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt, // whether that is later today or next morning after the office-hours break. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5b3e93ba7..8ad256a16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsInt, IsNumber, @@ -61,4 +62,15 @@ export class CreateContainerTrainScheduleDto { @IsInt() @Min(1) maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: + 'Reverse the wagon order on this train: the physically-last wagon becomes ' + + 'position 1. Frozen on the schedule; applied every time the wagon plan is ' + + 'rebuilt so the stored train order and the schedule order stay in sync.', + default: false, + }) + @IsOptional() + @IsBoolean() + reverseWagonOrder?: boolean; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts index 56cd9592b..6cf6a22fc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsInt, IsNumber, @@ -58,4 +59,15 @@ export class PreviewTrainScheduleDto { @IsInt() @Min(1) maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: + 'Reverse the wagon order on the train: the physically-last wagon becomes ' + + 'position 1. The composition and allocations are unchanged — only the order ' + + 'flips, applied at build so the stored train and schedule stay in sync.', + default: false, + }) + @IsOptional() + @IsBoolean() + reverseWagonOrder?: boolean; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index c5e6fa65f..b10736f0f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -67,4 +67,31 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsInt() @Min(1) paymentWindowMinutes?: number; + + // Booking-close offsets: minutes before departure the window shuts. The UI + // enters days/hours/minutes and converts to minutes. 0 or null clears the + // offset (close at departure). Nullable so it can be explicitly cleared. + @ApiPropertyOptional({ + example: 180, + nullable: true, + description: + 'Minutes before departure the IMPORT booking window closes; 0/null = close at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importCloseOffsetMinutes?: number | null; + + @ApiPropertyOptional({ + example: 1440, + nullable: true, + description: + 'Minutes before departure the EXPORT booking window closes; 0/null = close at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + exportCloseOffsetMinutes?: number | null; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 729063599..caa3ce24f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -79,4 +79,21 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; + + /** + * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set, + * the window's close (first cycle and every reopen) is capped at + * `departure − this`, instead of the default open+duration/departure cap. + * NULL or 0 = no offset (previous behaviour). + */ + @Column({ name: 'import_close_offset_minutes', type: 'int', nullable: true }) + importCloseOffsetMinutes?: number | null; + + /** + * Minutes before departure the EXPORT FCFS booking window shuts. When set, the + * export window closes at `departure − this` instead of at departure. NULL or + * 0 = no offset (export closes at departure, previous behaviour). + */ + @Column({ name: 'export_close_offset_minutes', type: 'int', nullable: true }) + exportCloseOffsetMinutes?: number | null; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index c4f2e7d6d..c0ff19b25 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -234,13 +234,17 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); + // Availability rows now report what the BOUNDED plan actually uses per + // type (never more than stock, so no shortfall on the rows themselves); + // the shortage is carried by the deferred bookings' own shortage rows. expect(result.fleetAvailability?.length).toBeGreaterThan(0); - expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0); + expect( + result.fleetAvailability?.every((row) => row.needed <= row.available), + ).toBe(true); expect(result.deferredBookings?.length).toBeGreaterThan(0); + expect(result.deferredBookings?.[0]?.reason).toContain('short'); expect(result.summary.wagonsNeeded).toBeLessThan(30); - expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe( - true, - ); + expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true); }); it('computes slot-based preview for Group A', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 0d9bf63c9..eb3fd7201 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -109,12 +109,13 @@ import { type FleetAvailabilityRow, } from './fleet-plan.util'; import { + applyWagonOrderReversal, planWagonsWithStock, - unboundedStock, type AllowedWagonTypeMap, type WagonStock, } from './wagon-plan-flex.util'; import { + containerWagonsForLines, expandBookingContainerUnits, getContainerSlotSequenceNos, roundTons, @@ -134,8 +135,10 @@ import { WagonTypeDimensions, } from './train-capacity.util'; import { + DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_TARE_TONS, + DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; @@ -180,6 +183,8 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) { ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes, ruleImportWindowLeadDays: cfg.importWindowLeadDays, ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null, + ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null, }; } @@ -204,6 +209,8 @@ export function effectiveWindowConfig( ruleReopenDelayMinutes?: number | null; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: number | null; + ruleImportCloseOffsetMinutes?: number | null; + ruleExportCloseOffsetMinutes?: number | null; }, liveCfg: BookingWindowConfig, ): BookingWindowConfig { @@ -220,6 +227,18 @@ export function effectiveWindowConfig( : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, paymentWindowMinutes: liveCfg.paymentWindowMinutes, + // The close offset is frozen per-schedule: a snapshot value of null means + // "created with no offset" and must NOT inherit a later live offset (that + // would retro-shrink an open train's window). Only a truly legacy row that + // predates the snapshot column (value undefined) falls back to live config. + importCloseOffsetMinutes: + schedule.ruleImportCloseOffsetMinutes !== undefined + ? schedule.ruleImportCloseOffsetMinutes + : liveCfg.importCloseOffsetMinutes, + exportCloseOffsetMinutes: + schedule.ruleExportCloseOffsetMinutes !== undefined + ? schedule.ruleExportCloseOffsetMinutes + : liveCfg.exportCloseOffsetMinutes, }; } @@ -588,7 +607,11 @@ export class TrainSchedulingService { trainScheduleId: query.trainScheduleId, day, }); - return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; + const tareDims = await this.loadWagonTareDims(); + return { + count: bookings.length, + items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)), + }; } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { @@ -633,6 +656,11 @@ export class TrainSchedulingService { if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + // Store 0 as null so "no offset" is a single canonical value. + if (dto.importCloseOffsetMinutes !== undefined) + row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null; + if (dto.exportCloseOffsetMinutes !== undefined) + row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null; // The booking desk supports three shapes: a same-day range // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an @@ -649,7 +677,9 @@ export class TrainSchedulingService { dto.windowDurationHours != null || dto.docReviewMinutes != null || dto.paymentWindowMinutes != null || - dto.exportBookingLeadHours != null; + dto.exportBookingLeadHours != null || + dto.importCloseOffsetMinutes !== undefined || + dto.exportCloseOffsetMinutes !== undefined; const saved = await this.dataSource .getRepository(TrainSchedulingGlobalRules) @@ -721,6 +751,17 @@ export class TrainSchedulingService { // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + // A per-schedule override isn't a close-offset control, so inherit the + // offset already frozen on the schedule (null = none), or the live one for + // legacy rows — the override must not silently drop the global offset. + importCloseOffsetMinutes: + schedule.ruleImportCloseOffsetMinutes !== undefined + ? schedule.ruleImportCloseOffsetMinutes + : liveCfg.importCloseOffsetMinutes, + exportCloseOffsetMinutes: + schedule.ruleExportCloseOffsetMinutes !== undefined + ? schedule.ruleExportCloseOffsetMinutes + : liveCfg.exportCloseOffsetMinutes, }; // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid @@ -1054,6 +1095,13 @@ export class TrainSchedulingService { const n = v == null ? NaN : Number(v); return Number.isFinite(n) ? n : fallback; }; + // Offsets are optional: a missing/unset value means "no offset", not a + // numeric default — keep it null so bookingCloseCutoff falls back to + // departure. Zero and negatives are treated as "no offset" too. + const offset = (v: unknown): number | null => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) && n > 0 ? n : null; + }; return { importWindowLeadDays: num(row?.importWindowLeadDays, 3), exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), @@ -1062,6 +1110,8 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes), + exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes), }; } @@ -1322,6 +1372,7 @@ export class TrainSchedulingService { direction, trainNumber: pairTrainNumber ?? undefined, maxWagons, + reverseWagonOrder: dto.reverseWagonOrder ?? false, ...windowFields, }), ); @@ -1400,6 +1451,10 @@ export class TrainSchedulingService { maxTrainWeightTons: dto.maxTrainWeightTons, maxTrainLengthMeters: dto.maxTrainLengthMeters, maxWagonsPerTrain: dto.maxWagonsPerTrain, + // The reverse-order choice is a property of the SCHEDULE, frozen when it was + // created — every (re)assignment rebuilds the plan under the same flag so the + // stored train order stays consistent no matter how bookings are added. + reverseWagonOrder: schedule.reverseWagonOrder ?? false, }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); @@ -1476,6 +1531,31 @@ export class TrainSchedulingService { }); } + // Every REQUESTED booking must have made the plan. Silently dropping a + // deferred one let the workspace "Add from pool" report success while the + // booking never boarded (e.g. it needs a PW2 wagon and the train only has + // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op. + // A stock shortage is a physical impossibility, so forceAssign cannot + // override it either. + const plannedIds = new Set(validation.bookings.map((b) => b.id)); + const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id)); + if (droppedRequested.length) { + const reasonById = new Map( + validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]), + ); + const details = droppedRequested.map( + (id) => + reasonById.get(id) ?? + `${id}: does not fit the train's wagon stock or capacity`, + ); + throw new BadRequestException({ + message: `Cannot allocate — ${details.join('; ')}`, + violations: details, + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + const { bookings, wagonPlan, warnings, deferredBookings } = validation; const totalWeightTons = validation.summary.totalWeightTons; const totalLengthMeters = validation.summary.totalLengthMeters; @@ -1817,13 +1897,15 @@ export class TrainSchedulingService { } const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const tareDims = await this.loadWagonTareDims(); const items = bookings .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') .map((b) => ({ id: b.id, reference: b.reference ?? null, customer: b.company?.name ?? null, - weightTons: b.cargoTotalWeightVgm, + // GROSS: cargo + tare of the wagons the booking occupies. + weightTons: this.grossBookingWeightTons(b, tareDims), loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, })); return { count: items.length, items }; @@ -3668,13 +3750,6 @@ export class TrainSchedulingService { const allowed = await this.loadAllowedWagonTypes(bookings); const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); - // Pure demand (unbounded stock) drives the availability report rows. - const demandPlan = planWagonsWithStock({ - bookings, - allowed, - stock: unboundedStock(allowed), - }).plan; - const originYardId = dto.originStationId; let stock: WagonStock; if (builtTrainId) { @@ -3711,10 +3786,24 @@ export class TrainSchedulingService { violations.push(...planned.configIssues); const fittingBookings = planned.fitting; const deferredBookings: DeferredBookingRow[] = planned.deferred; - const wagonPlan = planned.plan; + // Opt-in wagon-order reversal: flip the built plan's order (physically-last + // wagon → position 1) BEFORE legs are stamped and the plan is persisted, so + // the stored train order, allocations and snapshot all carry the reversed + // order together. No-op unless the schedule set the flag. + const wagonPlan = applyWagonOrderReversal( + planned.plan, + (dto as { reverseWagonOrder?: boolean }).reverseWagonOrder, + ); + // Availability rows come from the BOUNDED plan — the one that actually + // mixes wagon types against real stock. The old unbounded "pure demand" + // plan had infinite stock of every allowed type, so its tie-break parked a + // booking's ENTIRE need on one arbitrary type and produced false "Fleet + // shortage: need 30 PW2" warnings for bookings the real plan fits fine by + // mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through + // the deferred bookings' own shortage rows. const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability( - demandPlan, + planned.plan, stock.remainingByTypeId, stock.codesByTypeId, ); @@ -4805,7 +4894,10 @@ export class TrainSchedulingService { ); } - private mapEligibleBooking(booking: Booking) { + private mapEligibleBooking( + booking: Booking, + tareDims: Awaited>, + ) { return { id: booking.id, reference: booking.reference, @@ -4819,7 +4911,8 @@ export class TrainSchedulingService { .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'), quantity: booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0, - weightTons: roundTons(booking.cargoTotalWeightVgm), + // GROSS: cargo + tare of the wagons the booking occupies. + weightTons: this.grossBookingWeightTons(booking, tareDims), origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', @@ -6110,6 +6203,85 @@ export class TrainSchedulingService { } } + /** + * Per-wagon tare/payload for every wagon type, keyed by id, with the batch + * engine's representative fallbacks for bookings whose cargo/container type + * has no wagon type configured. Loaded once per request before mapping. + */ + private async loadWagonTareDims(): Promise<{ + byWagonTypeId: Map; + bulk: { tareWeightTons: number; capacityTons: number }; + container: { tareWeightTons: number; capacityTons: number }; + }> { + const types = await this.dataSource.getRepository(WagonType).find(); + const byWagonTypeId = new Map( + types.map((t) => [ + t.id, + { + tareWeightTons: Number(t.tareWeightTons) || 0, + capacityTons: Number(t.capacityTons) || 0, + }, + ]), + ); + return { + byWagonTypeId, + bulk: { + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS, + }, + container: { + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, + }, + }; + } + + /** + * Booking weight as the train actually hauls it: cargo VGM plus the tare of + * every wagon the booking occupies — the same gross axis the batch engine + * spends against the locomotive's pull limit. Wagon count mirrors the batch + * engine's sizing (stored wagonsRequired, TEU geometry for containers, + * tons ÷ payload for bulk — whichever is largest). + */ + private grossBookingWeightTons( + booking: Pick< + Booking, + | 'freightType' + | 'cargoTotalWeightVgm' + | 'wagonsRequired' + | 'bookingContainers' + | 'cargoType' + >, + tareDims: Awaited>, + ): number { + const cargo = Number(booking.cargoTotalWeightVgm ?? 0); + const fallback = + booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container; + // Same first-configured-type resolution the batch engine's dimsFor uses. + const wagonTypeId = + booking.freightType === 'BULK' + ? booking.cargoType?.wagonTypes?.[0]?.id + : (booking.bookingContainers ?? []) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wagonType) => wagonType.id) + .find((id): id is string => Boolean(id)); + const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined; + const dims = { + tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons, + capacityTons: typed?.capacityTons || fallback.capacityTons, + }; + + const stored = + booking.wagonsRequired && booking.wagonsRequired > 0 + ? Math.ceil(booking.wagonsRequired) + : 0; + const byLength = containerWagonsForLines(booking.bookingContainers ?? []); + const byWeight = + cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; + const wagons = Math.max(1, stored, byLength, byWeight); + return roundTons(cargo + wagons * dims.tareWeightTons); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { @@ -6118,6 +6290,9 @@ export class TrainSchedulingService { ); const allocationIds = allocations.map((a) => a.id); const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + // Booking weights are reported GROSS (cargo + wagon tare) — the number the + // locomotive actually hauls and the axis its pull limit is compared against. + const tareDims = await this.loadWagonTareDims(); // Import-from-Djibouti trains can only dispatch once loading is confirmed // (loadedOnTrainAt on the operation). Other directions have no departure @@ -6406,7 +6581,9 @@ export class TrainSchedulingService { id: sb.booking?.id ?? sb.bookingId, reference: sb.booking?.reference ?? null, customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null, - weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), + weightTons: sb.booking + ? this.grossBookingWeightTons(sb.booking, tareDims) + : 0, status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index bb9adf319..5870d3785 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -1,6 +1,10 @@ import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { planWagonsWithStock } from './wagon-plan-flex.util'; +import { + applyWagonOrderReversal, + planWagonsWithStock, +} from './wagon-plan-flex.util'; +import type { WagonPlanSlot } from './wagon-plan.util'; const nw6: WagonType = { id: 'wt-nw6', @@ -130,3 +134,56 @@ describe('planWagonsWithStock — shortage detail', () => { expect(result.deferred[0]?.shortage).toBeNull(); }); }); + +describe('applyWagonOrderReversal', () => { + const slot = ( + seq: number, + wagonTypeId: string, + bookingId: string, + ): WagonPlanSlot => + ({ + sequenceNo: seq, + wagonTypeId, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 25, + allocations: [{ bookingId }], + }) as unknown as WagonPlanSlot; + + const plan: WagonPlanSlot[] = [ + slot(1, 'wt-a', 'BKG-A'), + slot(2, 'wt-b', 'BKG-B'), + slot(3, 'wt-c', 'BKG-C'), + ]; + + it('returns the plan unchanged when the flag is false/absent', () => { + expect(applyWagonOrderReversal(plan, false)).toBe(plan); + expect(applyWagonOrderReversal(plan, undefined)).toBe(plan); + expect(applyWagonOrderReversal(plan, null)).toBe(plan); + }); + + it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => { + const reversed = applyWagonOrderReversal(plan, true); + // Physically-last wagon (was seq 3, wt-c) is now position 1. + expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']); + expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + }); + + it('keeps each booking with its own wagon — only the position changes', () => { + const reversed = applyWagonOrderReversal(plan, true); + // The booking that was in the last wagon now sits at sequenceNo 1. + expect(reversed[0].sequenceNo).toBe(1); + expect( + (reversed[0].allocations as { bookingId: string }[])[0].bookingId, + ).toBe('BKG-C'); + expect( + (reversed[2].allocations as { bookingId: string }[])[0].bookingId, + ).toBe('BKG-A'); + }); + + it('does not mutate the input plan', () => { + applyWagonOrderReversal(plan, true); + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index ad4c29aa1..6a3c1c49f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -340,6 +340,31 @@ export function planWagonsWithStock(params: { }; } +/** + * Reverse the wagon ORDER of a built plan when a schedule opts in. + * + * The plan comes out of planWagonsWithStock ordered by booking scheduling order + * (first slot opened = sequenceNo 1). When `reverse` is set, the physically-last + * wagon becomes wagon #1: the slot objects — and the bookings already allocated + * into each — travel WITH their slot, so only the position numbers flip. The + * physical composition, which booking is in which wagon, and every per-slot + * field are untouched; sequenceNo is renumbered 1..N over the reversed array. + * + * This single flip is the whole feature: persistTrainSetWagons writes these + * sequenceNos, the snapshot re-sorts by them, and the board/allocation views all + * read them — so the stored train order and the schedule order stay identical, + * just reversed. A false/absent flag returns the plan unchanged. + */ +export function applyWagonOrderReversal( + plan: WagonPlanSlot[], + reverse: boolean | null | undefined, +): WagonPlanSlot[] { + if (!reverse) return plan; + return [...plan] + .reverse() + .map((slot, index) => ({ ...slot, sequenceNo: index + 1 })); +} + /** Unbounded stock — used to compute pure demand for availability reporting. */ export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock { const remainingByTypeId = new Map(); diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6fba64010..2c4e65ae1 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -99,11 +99,10 @@ Compensation shall be based on the market value of the cargo, in accordance with a( "pricing", "Contract Price and Payment Terms", - `Rail transport to Galaan Multipurpose Port: USD 59.4 per metric ton. -Djibouti handling (first-mile, port handling and loading, and documentation): USD 18 (eighteen) per metric ton for cargo from the Free Zone; USD 20 (twenty) per metric ton for cargo from the Old Port or DMP. -Lashing materials shall be charged at USD 150 (one hundred fifty) per wagon and wood at USD 50 (fifty) per wagon when provided by the Service Provider; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. + `The applicable railway freight, Djibouti handling, and any additional service and surcharge rates for this contract are set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per service. Each wagon shall be loaded up to a maximum of seventy (70) metric tons; for billing purposes one full wagon shall be deemed equivalent to this volume. -The price for last-mile delivery shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. +Where lashing materials and wood are provided by the Service Provider, they shall be charged at the applicable rate set out in the Rate Schedule; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. +The price for last-mile delivery, where not listed in the Rate Schedule, shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. Payments shall be made 100% in advance in USD.`, ), a( @@ -228,15 +227,12 @@ A party wishing to claim protection in respect of a force majeure event shall, a a( "pricing", "Contract Price and Terms of Payment", - `The price of bulk cargo transportation from the loading station to Nagad shall be USD 696 (six hundred ninety-six) per wagon. + `The price of bulk cargo transportation from the loading station to Nagad, together with any applicable demurrage and surcharge rates, is set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per wagon. Payment for transport services shall be made in Birr based on the selling price of USD to Birr on the date of payment set by the Commercial Bank of Ethiopia. If there is an increment or decrement of the USD exchange rate to Birr between the date of payment and the date the wagon/train number is provided to the Client, either the Client shall make the additional payment to the Service Provider or the Service Provider shall refund the difference from the initial payment to the Client. The cost of loading at the loading station and unloading at Nagad shall be covered by the Client and is not part of this contract agreement. The Client shall pay 100% of the contract price in advance. -The Client shall pay a demurrage fee for occupied wagons as follows: -- Wagons occupied between 1 and 3 days: USD 193 per wagon per day. -- Wagons occupied between 4 and 7 days: USD 290 per wagon per day. -- Wagons occupied 8 days and above: USD 590 per wagon per day. +The Client shall pay a demurrage fee for occupied wagons at the rate set out in the Rate Schedule for the applicable occupancy band. Demurrage payment shall be made in Birr based on the selling price of USD to Birr set by the Commercial Bank of Ethiopia on the date of the demurrage occurrence.`, ), a( @@ -361,7 +357,7 @@ The affected party shall notify the other party in writing within a reasonable p a( "pricing", "Contract Price", - `The price for transporting cargo from the origin freight yard to the destination freight yard shall be USD 400 (four hundred) per wagon. + `The price for transporting cargo from the origin freight yard to the destination freight yard is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per wagon. Each wagon shall be loaded with a maximum of 70 (seventy) metric tons. Payment for transport services may be made in Ethiopian Birr, based on the Commercial Bank of Ethiopia's official selling exchange rate of USD to Birr on the date of payment. If the exchange rate changes between the payment and the wagon assignment date, payment adjustments will be made accordingly. @@ -504,9 +500,7 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Contract Price and Terms of Payment", - `From SGTD to Dire Dawa dry port, the rate is USD 919 per one 40ft or USD 942 per two 20ft containers with empty return; USD 762 per one 40ft or USD 780 per two 20ft containers without empty return. -From SGTD to Modjo, the rate is USD 1,781 per one 40ft or USD 1,808 per two 20ft containers with empty return, and USD 1,507 per one 40ft or two 20ft containers without empty return. -From SGTD to Galaan Multipurpose Port, the rate is USD 1,916 per one 40ft or USD 1,944 per two 20ft containers with empty return, and USD 1,676 per one 40ft or USD 1,690 per two 20ft containers without empty return. + `The railway transportation rate for each corridor (per one 40ft container or per two 20ft containers, with or without empty return where applicable) is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per container. If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. Gross weight shall be the total sum of cargo, packing, and container tare weight. Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. @@ -645,12 +639,9 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Pricing and Payment Terms", - `Railway transportation charges from GMP to SGTD: USD 819 (eight hundred nineteen) per 40ft container; USD 834 (eight hundred thirty-four) per two (2) 20ft containers. -Railway transportation charges from Modjo to SGTD: USD 725 (seven hundred twenty-five) per 40ft container; USD 725 (seven hundred twenty-five) per two (2) 20ft containers. -Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge of USD 10 (ten) shall apply for each excess metric ton. -Freight forwarding and customs clearance charges from GMP to SGTD: USD 540 (five hundred forty) per 40ft container; USD 349 (three hundred forty-nine) per 20ft container. -Freight forwarding and customs clearance charges from Modjo to SGTD: USD 569 (five hundred sixty-nine) per 40ft container; USD 389 (three hundred eighty-nine) per 20ft container. -For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to an extra charge of USD 50 per document. + `The railway transportation charges and the freight forwarding and customs clearance charges for each corridor (per 40ft container and per two 20ft containers) are set out in the Rate Schedule immediately below, expressed as unit prices per origin → destination lane and per container. +Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge shall apply for each excess metric ton at the overweight rate set out in the Rate Schedule. +For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to the extra-document charge set out in the Rate Schedule. Payment must be supported by an official receipt before cargo departs from Galaan Multipurpose Port/Modjo. If the Client uses PIL Shipping Line, any local charge incurred will be covered by the Client as per the invoice issued by the shipping line. If storage or demurrage occurs due to Client-related issues (delay in document submission, payment delay, or any other Client-related reason), the Client shall pay the corresponding charges; charges apply per day after the free storage period, based on the invoice and SGTD tariff. @@ -797,7 +788,7 @@ Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.` a( "pricing", "Contract Price and Terms of Payment", - `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route shall be as per the prevailing EDR domestic container tariff, as set out in the commercial schedule of this contract. + `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route is set out in the Rate Schedule immediately below, expressed as a unit price per origin → destination lane and per container. If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. Gross weight shall be the total sum of cargo, packing, and container tare weight. Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 147f2ae54..072c559c6 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -416,6 +416,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // Cargo-securing / lashing — flat fee, billed once per booking whose + // cargo type has hasLashing = true. + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" }, // ── First/last-mile road haulage (per km) — drives the mile invoices ── { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 5ad63a605..867a0f895 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -87,7 +87,8 @@ function phaseCountdown( } } -/** Cargo weight already allocated to this train (sum of on-train bookings). */ +/** GROSS weight already on this train (each booking's cargo + wagon tare) — + * compared against the locomotive pull limit, which is a gross ceiling. */ function usedWeight(schedule: TrainScheduleDetail): number { return (schedule.bookings ?? []).reduce( (sum, b) => sum + (Number(b.weightTons) || 0), diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index cf917f5ff..dbf23b6ee 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -52,6 +52,8 @@ interface CargoNode extends RuleEngineRecord { code?: string; parentGroupId?: string | null; requiresDirectorApproval?: boolean; + /** When true, bookings of this cargo type incur the flat LASHING surcharge. */ + hasLashing?: boolean; /** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ unitOfMeasure?: string | null; /** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */ @@ -97,6 +99,9 @@ const FORM_FIELDS: FormFieldDef[] = [ ((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id), }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, + // When on, every booking of this cargo type is charged the flat LASHING + // surcharge (a rate with trigger = Lashing). + { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 00531d7b8..7cefc6a3b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -146,6 +146,7 @@ const RATE_TRIGGERS = [ { label: "Empty container return", value: "WITH_RETURN" }, { label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Consolidation", value: "CONSOLIDATION" }, + { label: "Lashing (flat, per booking)", value: "LASHING" }, { label: "Cancellation", value: "CANCELLATION" }, { label: "Demurrage", value: "DEMURRAGE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, @@ -192,6 +193,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { case "CUSTOMS_CLEARANCE": // Flat per clearance (ONE_TIME) / per shipment request (GENERAL). return ["FLAT"]; + case "LASHING": + // Flat cargo-securing fee, billed once per booking. + return ["FLAT"]; case "CONSOLIDATION": case "SHIPPING_LINE": case "PIL_EXTRA_FEE": @@ -278,6 +282,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select parent cargo type (optional)", }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, + { name: "hasLashing", label: "Charge lashing fee", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 640b9bde1..2d7e69cc1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -584,9 +584,16 @@ export default function TrainScheduleV2DetailPage() { } if (key === "wagon" && displayWagonPlan.length) { return ( - - {displayWagonPlan.length} wagons - + + {schedule.reverseWagonOrder ? ( + + Reversed order + + ) : null} + + {displayWagonPlan.length} wagons + + ); } if (key === "container" && containerUnits.length) { @@ -1056,9 +1063,19 @@ export default function TrainScheduleV2DetailPage() { }, { label: "Wagons / load", + // Gross: cargo load + the tare of every wagon in the consist — the + // weight the locomotive actually hauls. value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${ - schedule.trainSet?.totalWeightTons ?? 0 + Math.round( + ((schedule.trainSet?.totalWeightTons ?? 0) + + (schedule.trainSet?.wagons ?? []).reduce( + (sum, w) => sum + (Number(w.tareWeightTons) || 0), + 0, + )) * + 100, + ) / 100 }T`, + hint: "gross · wagon tare + cargo", icon: Weight, }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 3cc91011d..f40bd9526 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -5,6 +5,7 @@ import { Box, Button, Card, + Checkbox, Group, Menu, Modal, @@ -121,6 +122,7 @@ export default function TrainScheduleV2ListPage() { const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); const [trainId, setTrainId] = useState(""); + const [reverseWagonOrder, setReverseWagonOrder] = useState(false); // Recomputed each time the create modal opens so a long-lived tab can't keep // offering a stale "now" as the earliest selectable departure. const minScheduleDate = useMemo( @@ -513,10 +515,12 @@ export default function TrainScheduleV2ListPage() { routeId, scheduleDate: new Date(scheduleDate).toISOString(), trainId, + reverseWagonOrder, }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); + setReverseWagonOrder(false); setCreateOpen(false); navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`); } catch (err) { @@ -805,6 +809,12 @@ export default function TrainScheduleV2ListPage() { : "Select a route first" } /> + setReverseWagonOrder(e.currentTarget.checked)} + />