add lashing surcharge for cargo types with hasLashing flag

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
Marshal
2026-07-17 23:25:53 +00:00
parent 6467173c76
commit 3a1a08b1e1
59 changed files with 1919 additions and 140 deletions

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;

View File

@@ -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<string, string> = {
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<Record<Rate['appliesTo'], string>> = {
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<Record<Rate['trigger'], string>> = {
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<RateSchedule> {
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());
}
}

View File

@@ -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,

View File

@@ -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)),
}));

View File

@@ -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,

View File

@@ -25,25 +25,9 @@
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.unitRates}}
<h3>Unit Rate Schedule</h3>
<p>
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.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#unless rateSchedule.isEmpty}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{else}}
<h3>Charges</h3>
<table class="schedule">
@@ -76,7 +60,7 @@
</tr>
</tbody>
</table>
{{/if}}
{{/unless}}
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in

View File

@@ -20,5 +20,9 @@
{{/each}}
</ol>
{{/if}}
{{#if (eq id "pricing")}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{/if}}
</section>
{{/each}}

View File

@@ -0,0 +1,55 @@
{{#if rateSchedule.isEmpty}}
<p class="muted-note">
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.
</p>
{{else}}
<p>
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.
</p>
<table class="schedule">
<thead>
<tr>
<th>Route / Service</th>
<th>Cargo / Equipment</th>
<th>Unit price</th>
</tr>
</thead>
<tbody>
{{#if rateSchedule.freightLanes.length}}
<tr><th colspan="3">Railway Freight — Origin → Destination</th></tr>
{{#each rateSchedule.freightLanes}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.additionalServices.length}}
<tr><th colspan="3">Additional Services</th></tr>
{{#each rateSchedule.additionalServices}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.surcharges.length}}
<tr><th colspan="3">Surcharges, Demurrage &amp; Fees</th></tr>
{{#each rateSchedule.surcharges}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
</tbody>
</table>
{{/if}}

View File

@@ -134,26 +134,8 @@
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
<h3>Published Rate Schedule</h3>
{{> rate_schedule}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP COLUMN IF EXISTS has_lashing;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS reverse_wagon_order;
`);
}
}

View File

@@ -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<void> {
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<void> {
// No-op: the refreshed pricing prose is the correct forward state; reverting
// to hardcoded figures would reintroduce the rate-schedule contradiction.
}
}

View File

@@ -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);
});
});

View File

@@ -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' }),
);
});
});

View File

@@ -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:

View File

@@ -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)',

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -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()

View File

@@ -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;

View File

@@ -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';

View File

@@ -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':

View File

@@ -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',

View File

@@ -6,6 +6,12 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
/**
* 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<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;

View File

@@ -25,6 +25,22 @@ export class RatesRepository implements IRatesRepository {
.getMany();
}
findLiveRatesDetailed(): Promise<Rate[]> {
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

View File

@@ -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:

View File

@@ -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<Rate[]> {
return this.repository.findLiveRatesDetailed();
}
/** Get a rate by ID. */
async findById(id: string): Promise<Rate> {
const entity = await this.repository.findById(id);

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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<string, unknown> = {}) => ({
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);
});
});
});

View File

@@ -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(

View File

@@ -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. */

View File

@@ -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 817 → 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.

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
) {
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<string, { tareWeightTons: number; capacityTons: number }>;
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<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): 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,

View File

@@ -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']);
});
});

View File

@@ -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<string, number>();

View File

@@ -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.

View File

@@ -416,6 +416,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ 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" },

View File

@@ -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),

View File

@@ -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" },
];

View File

@@ -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" },
],
},

View File

@@ -584,9 +584,16 @@ export default function TrainScheduleV2DetailPage() {
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
<Group gap="xs">
{schedule.reverseWagonOrder ? (
<Badge variant="light" color="orange" radius="sm">
Reversed order
</Badge>
) : null}
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
</Group>
);
}
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,
},
{

View File

@@ -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"
}
/>
<Checkbox
label="Reverse wagon order"
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."
checked={reverseWagonOrder}
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel

View File

@@ -7,6 +7,25 @@ import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
/**
* Coerce an API rules object into editable form state: `numeric` columns arrive
* as strings ("250.00") and nullable offsets arrive as null — map both to a real
* number, or "" for blank/null so a NumberInput edits cleanly and a cleared
* offset stays blank.
*/
function toFormState(
rules: TrainSchedulingGlobalRules,
): Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> {
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
return numeric;
}
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
@@ -20,18 +39,7 @@ export default function TrainSchedulingGlobalRulesPage() {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
// `numeric` columns come back from the API as strings (e.g. "250.00").
// Coerce every field to a real number so Mantine's controlled
// NumberInput edits cleanly (a string value fights the caret) and the
// default can be cleared and replaced.
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
setForm(numeric);
setForm(toFormState(rules));
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
@@ -73,10 +81,23 @@ export default function TrainSchedulingGlobalRulesPage() {
payload[key] = num;
}
// Close offsets are optional: a blank box means "no offset" (window closes at
// departure) and is sent as 0, which the API stores as null. A filled box is
// sent as its minute value.
const offsetFields: (keyof TrainSchedulingGlobalRules)[] = [
"importCloseOffsetMinutes",
"exportCloseOffsetMinutes",
];
for (const key of offsetFields) {
const raw = form[key];
const num = raw === "" || raw == null ? 0 : Number(raw);
payload[key] = Number.isFinite(num) ? num : 0;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
setForm(toFormState(updated));
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
@@ -198,6 +219,37 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
</Stack>
</Card>
<Card maw={720} mt="md">
<Stack gap="md">
<PageHeader
title="Booking close offset"
subtitle="How long before departure a schedule stops accepting bookings. e.g. a 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to close exactly at departure. Set separately for import and export."
/>
<DurationField
label="Import close offset"
description="Import/domestic booking windows close this long before departure (blank = at departure)"
value={form.importCloseOffsetMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, importCloseOffsetMinutes: value }))
}
min={0}
disabled={loading}
/>
<DurationField
label="Export close offset"
description="Export booking windows close this long before departure (blank = at departure)"
value={form.exportCloseOffsetMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, exportCloseOffsetMinutes: value }))
}
min={0}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules

View File

@@ -120,6 +120,10 @@ export interface TrainSchedulingGlobalRules {
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
/** Minutes before departure the import window closes; null = close at departure. */
importCloseOffsetMinutes: number | null;
/** Minutes before departure the export window closes; null = close at departure. */
exportCloseOffsetMinutes: number | null;
}
export interface TrainSchedulePreviewResponse {
@@ -534,6 +538,8 @@ export interface TrainScheduleDetail {
trainName?: string | null;
} | null;
direction?: string | null;
/** Wagon order reversed on this train (physically-last wagon = position 1). */
reverseWagonOrder?: boolean;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
/** True when loading is already confirmed (or not required for this direction). */
@@ -811,6 +817,8 @@ export interface CreateTrainSchedulePayload {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
}
export interface AssignBookingsPayload {

View File

@@ -25,6 +25,7 @@ import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDoc
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker";
import { DayAvailabilityHint } from "./DayAvailabilityHint";
import type { ClearanceFlowController } from "./useClearanceFlow";
const BORDER = "#E6ECF2";
@@ -233,6 +234,13 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
value={scheduledDate}
onChange={setScheduledDate}
/>
{scheduledDate && (
<DayAvailabilityHint
bookingId={booking.id}
date={scheduledDate}
tradeDirection={booking.tradeDirection}
/>
)}
</Box>
)}

View File

@@ -0,0 +1,93 @@
import { Alert, Loader, Text } from "@mantine/core";
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
interface DayAvailabilityHintProps {
bookingId: string;
/** yyyy-MM-dd (or ISO) — the day the customer has picked. */
date: string;
/** EXPORT never splits: a shortfall means the whole booking must move. */
tradeDirection: "IMPORT" | "EXPORT";
}
/**
* Advisory free-wagon count for the selected shipment day. It never blocks —
* the real authorities are the batch engine (import) and the request-operation
* gate (export). This is a planning nudge so the customer knows, before they
* proceed, whether the day has room and whether a split/rejection is likely.
*
* - EXPORT: a shortfall is a hard problem (no split) — proceeding will be
* rejected — so we warn in red and quote the largest single-train leftover.
* - IMPORT/DOMESTIC: a shortfall just means the batch may split the booking or
* defer a remainder to a later window — an amber heads-up, not a blocker.
*/
export function DayAvailabilityHint({
bookingId,
date,
tradeDirection,
}: DayAvailabilityHintProps) {
const { data, isLoading, isError } = useQuery({
...api.bookings.getDayAvailability.queryOptions({ bookingId, date }),
enabled: Boolean(bookingId && date),
});
if (!date) return null;
if (isLoading) {
return (
<Text fz="12px" c="dimmed" mt="xs">
<Loader size={11} mr={6} style={{ verticalAlign: "middle" }} />
Checking wagon availability for this day
</Text>
);
}
// The advisory is best-effort; if it fails the customer can still proceed and
// the server-side gate stays authoritative, so we simply show nothing.
if (isError || !data) return null;
if (!data.trainsForDay) {
return (
<Alert color="gray" radius="md" icon={<Info size={15} />} mt="xs" p="xs">
<Text fz="12px">
No departure on this day carries your route pick another day.
</Text>
</Alert>
);
}
if (data.fits) {
return (
<Text fz="12px" c="teal" mt="xs">
<CheckCircle2 size={13} style={{ verticalAlign: "middle" }} />{" "}
{data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"} available on
this day your booking fits.
</Text>
);
}
// Shortfall.
if (tradeDirection === "EXPORT") {
return (
<Alert color="red" radius="md" icon={<AlertCircle size={15} />} mt="xs" p="xs">
<Text fz="12px">
Not enough space on this day. An export booking must ride one train
whole, so it can't be split the largest train still has room for
about {data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"}.
Reduce the booking or pick another day.
</Text>
</Alert>
);
}
return (
<Alert color="yellow" radius="md" icon={<AlertCircle size={15} />} mt="xs" p="xs">
<Text fz="12px">
About {data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"} are free
on this day less than your booking needs. You can still proceed: the
operations team will load what fits and the rest returns for you to
rebook on a later day.
</Text>
</Alert>
);
}

View File

@@ -460,6 +460,13 @@ export const api = {
({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
),
getDayAvailability: endpoint<
{ bookingId: string; date: string },
Freight.DayAvailabilityResponse
>("train-scheduling", "dayAvailability", ({ bookingId, date }) =>
bookingsService.getDayAvailability(bookingId, date),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",

View File

@@ -444,6 +444,18 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
// Advisory free-wagon count for a shipment day (planning hint, not enforced).
getDayAvailability: async (
bookingId: string,
date: string,
): Promise<Freight.DayAvailabilityResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/day-availability`,
{ params: { date } },
);
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).

View File

@@ -948,6 +948,23 @@ export interface AvailableDaysResponse {
days: string[];
}
/**
* Advisory free-wagon count for a shipment day a customer is considering — a
* planning hint, never enforced (the batch engine and the export gate are the
* real authorities). `trainsForDay` is false when no departure carries the leg.
*
* - EXPORT: `fits` = a single open train that day can carry the WHOLE booking
* (export never splits); `freeWagons` = the largest single-train leftover.
* - IMPORT/DOMESTIC: `freeWagons` = TOTAL room across the day's trains for the
* booking's wagon type; `fits` = that total covers the booking. The batch may
* still split the booking or defer a remainder to a later window.
*/
export interface DayAvailabilityResponse {
fits: boolean;
freeWagons: number;
trainsForDay: boolean;
}
export interface BookableScheduleLocomotive {
id: string;
code: string;