mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder';
|
||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
|
||||
/** Minimal Rate factory for the builder unit tests. */
|
||||
function rate(partial: Partial<Rate>): Rate {
|
||||
return {
|
||||
trigger: 'ALWAYS',
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
...partial,
|
||||
} as Rate;
|
||||
}
|
||||
|
||||
describe('ContractRateScheduleBuilder', () => {
|
||||
const LIVE: Rate[] = [
|
||||
rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import
|
||||
rateType: 'CONTAINER_EXPORT',
|
||||
rateValue: 819,
|
||||
originYard: { label: 'GMP' } as never,
|
||||
destinationYard: { label: 'SGTD' } as never,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'BULK', // wrong freight — filtered out for a container contract
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'BULK_IMPORT',
|
||||
rateUnit: 'PER_WAGON',
|
||||
rateValue: 100,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'FIRST_MILE',
|
||||
trigger: 'ALWAYS',
|
||||
tradeDirection: null,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateValue: 50,
|
||||
}),
|
||||
rate({
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'CUSTOMS_CLEARANCE',
|
||||
tradeDirection: null,
|
||||
rateType: 'CUSTOMS_CLEARANCE',
|
||||
rateUnit: 'FLAT',
|
||||
rateValue: 120,
|
||||
}),
|
||||
];
|
||||
|
||||
const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => {
|
||||
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) };
|
||||
return new ContractRateScheduleBuilder(service as never).build(dir, freight);
|
||||
};
|
||||
|
||||
it('shows only import container lanes for an import container contract', async () => {
|
||||
const s = await build('IMP', 'CON');
|
||||
expect(s.freightLanes).toHaveLength(1);
|
||||
expect(s.freightLanes[0]).toMatchObject({
|
||||
route: 'Negad → Mojo Dry Port',
|
||||
cargo: '40ft GP',
|
||||
currency: 'USD',
|
||||
amount: '200',
|
||||
unit: 'per container',
|
||||
});
|
||||
});
|
||||
|
||||
it('always lists route-agnostic services and surcharges', async () => {
|
||||
const s = await build('IMP', 'CON');
|
||||
expect(s.additionalServices).toHaveLength(1);
|
||||
expect(s.additionalServices[0].route).toBe('First-mile pickup by truck');
|
||||
expect(s.surcharges).toHaveLength(1);
|
||||
expect(s.surcharges[0].route).toBe('Customs clearance service');
|
||||
});
|
||||
|
||||
it('excludes container lanes from a bulk contract', async () => {
|
||||
const s = await build('IMP', 'BULK');
|
||||
expect(s.freightLanes).toHaveLength(1);
|
||||
expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' });
|
||||
});
|
||||
|
||||
it('flags an empty schedule when nothing priced matches', async () => {
|
||||
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) };
|
||||
const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON');
|
||||
expect(s.isEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,5 +20,9 @@
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
{{#if (eq id "pricing")}}
|
||||
<h3>Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/each}}
|
||||
|
||||
@@ -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 & Fees</th></tr>
|
||||
{{#each rateSchedule.surcharges}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
@@ -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 ───────────────────────────── --}}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Repair for environments missing the GPS tracking tables.
|
||||
*
|
||||
* AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but
|
||||
* some databases have it RECORDED in public.migrations without the tables ever
|
||||
* landing. TypeORM never re-runs a recorded migration, so those environments
|
||||
* stay broken through any number of restarts — the GT06 listener accepts tracker
|
||||
* packets on its TCP port regardless of schema state and fails per packet with
|
||||
* `relation "freight.gps_devices" does not exist`, dropping position fixes.
|
||||
*
|
||||
* This re-issues the same DDL under a new name so it is applied afresh. Every
|
||||
* statement is IF NOT EXISTS, so it is a no-op where the tables already exist
|
||||
* and safe on every environment.
|
||||
*
|
||||
* Kept byte-identical to the original DDL on purpose: this must converge on the
|
||||
* schema the entities expect, not a variant of it.
|
||||
*/
|
||||
export class RepairGpsTrackingTables2300000000000 implements MigrationInterface {
|
||||
name = "RepairGpsTrackingTables2300000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.gps_devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
imei varchar(20) NOT NULL UNIQUE,
|
||||
name varchar,
|
||||
vehicle_id uuid REFERENCES freight.vehicles(id),
|
||||
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
|
||||
last_seen_at timestamptz,
|
||||
last_lat numeric(10,6),
|
||||
last_lng numeric(10,6),
|
||||
last_speed numeric(6,2),
|
||||
last_course int,
|
||||
last_fix_at timestamptz,
|
||||
voltage_level int,
|
||||
gsm_level int,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
|
||||
ON freight.gps_devices (vehicle_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.gps_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
device_id uuid NOT NULL,
|
||||
imei varchar(20) NOT NULL,
|
||||
vehicle_id uuid,
|
||||
lat numeric(10,6) NOT NULL,
|
||||
lng numeric(10,6) NOT NULL,
|
||||
speed numeric(6,2) NOT NULL DEFAULT 0,
|
||||
course int NOT NULL DEFAULT 0,
|
||||
satellites int NOT NULL DEFAULT 0,
|
||||
positioned boolean NOT NULL DEFAULT false,
|
||||
gps_time timestamptz NOT NULL,
|
||||
alarm int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
|
||||
ON freight.gps_positions (device_id, gps_time)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
|
||||
ON freight.gps_positions (vehicle_id, gps_time)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: dropping the tables would discard tracker history on environments
|
||||
// where this migration was the one that created them. AddGpsTracking owns
|
||||
// the teardown.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Refresh the `pricing` article body of the six seeded contract templates to
|
||||
* the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per
|
||||
* wagon") are now rendered from the LIVE rate config instead of frozen prose,
|
||||
* so any template whose pricing article still carries a hardcoded price token
|
||||
* is rewritten to the current seed text.
|
||||
*
|
||||
* The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original
|
||||
* prose (which always quoted a currency + figure) and matches neither an
|
||||
* already-migrated body nor a hand-edited one that adopted the schedule
|
||||
* wording — so admin edits are preserved. Idempotent: after the rewrite the
|
||||
* price token is gone, so a re-run is a no-op. Fresh databases seed the new
|
||||
* text directly (CreateContractTemplates imports the same seed), making this
|
||||
* a targeted backfill for databases seeded before the seed changed.
|
||||
*/
|
||||
const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]';
|
||||
|
||||
export class RefreshContractPricingArticles2360000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const pricing = seed.articles.find((a) => a.id === 'pricing');
|
||||
if (!pricing) continue;
|
||||
|
||||
// Rewrite only the article whose id = 'pricing', in place, and only when
|
||||
// its body still quotes a hardcoded currency figure. jsonb_agg keeps the
|
||||
// rest of the article (id/title/order) and every other article intact.
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates AS t
|
||||
SET articles = (
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem->>'id' = 'pricing'
|
||||
THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true)
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE t.code = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(t.articles) AS x
|
||||
WHERE x->>'id' = 'pricing'
|
||||
AND x->>'body' ~ $3
|
||||
);
|
||||
`,
|
||||
[seed.code, pricing.body, HARDCODED_PRICE_TOKEN],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Irreversible in practice — the original per-lane figures are not restored.
|
||||
* A no-op down keeps the migration reversible-by-contract without
|
||||
* resurrecting stale hardcoded prices.
|
||||
*/
|
||||
public async down(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ export class BookingTransitionService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
@Inject(forwardRef(() => BookingContractService))
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@@ -1036,6 +1037,40 @@ 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) {
|
||||
// With export split ON the booking no longer has to ride ONE train whole:
|
||||
// the largest fitting part is offered and the leftover rebooks on the next
|
||||
// train. So the day is only unbookable when NO export train that day has
|
||||
// any room at all — reject on the day total, not on a single-train fit.
|
||||
// With the flag off this stays the strict whole-booking gate.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
scheduledBooking,
|
||||
eatDay(date),
|
||||
"EXPORT",
|
||||
);
|
||||
if (!fitting.length) {
|
||||
throw new ConflictException(
|
||||
"No export train on this day has space left — pick another shipment day.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
scheduledDate: date,
|
||||
@@ -1045,6 +1080,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:
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
* contracts report per size; bulk reports one tonnage figure. `null` when the
|
||||
* contract has no live split chain. Consumed by the remainder-placement engine
|
||||
* to size the auto-created remainder booking.
|
||||
*/
|
||||
export type SplitOutstanding = {
|
||||
bySize: Map<string, { total: number; outstanding: number }>;
|
||||
bulk: { total: number; outstanding: number } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
@@ -966,9 +979,11 @@ export class ContractBookingService {
|
||||
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
|
||||
* contract has no live split booking.
|
||||
*/
|
||||
private async splitOutstanding(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
|
||||
/**
|
||||
* Public: the remainder-placement engine reads this to size the auto-created
|
||||
* remainder booking. Returns `null` when there is no live split chain.
|
||||
*/
|
||||
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
|
||||
const first = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
@@ -1015,6 +1030,25 @@ export class ContractBookingService {
|
||||
const probe = await this.buildExportProbe(contract, route, dto, yards);
|
||||
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||
if (report.scheduleId) return;
|
||||
|
||||
// With export split ON a booking no longer has to ride ONE train whole: the
|
||||
// largest fitting part is offered and the leftover is rebooked on the next
|
||||
// train. Rejecting on the single-train fit here would block exactly the
|
||||
// bookings the split exists to serve — including the auto-created remainder,
|
||||
// which by definition did not fit the train it was split off. Fall back to
|
||||
// the day total: unbookable only when NO export train that day has room.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
probe,
|
||||
eatDay(new Date(dto.scheduledDate)),
|
||||
'EXPORT',
|
||||
);
|
||||
if (fitting.length > 0) return;
|
||||
throw new BadRequestException(
|
||||
'No export train on this day has space left — pick another shipment day.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
report.fullMessage ?? 'Not enough train space for this day.',
|
||||
);
|
||||
@@ -1636,6 +1670,8 @@ export class ContractBookingService {
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -74,9 +74,14 @@ export class CreateRateDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
rateValue!: number;
|
||||
|
||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||
@ApiPropertyOptional({
|
||||
enum: RATE_UNITS,
|
||||
description:
|
||||
'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit!: string;
|
||||
rateUnit?: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -60,15 +68,21 @@ export class RatesService {
|
||||
private resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
): Rate['rateUnit'] {
|
||||
// Overweight is per-ton, full stop.
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return requestedUnit;
|
||||
@@ -264,7 +278,11 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
@@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => RemainderPlacementService))
|
||||
private readonly remainderPlacement?: RemainderPlacementService,
|
||||
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Auto-place a paid booking's split remainder onto the next fitting train.
|
||||
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
|
||||
*/
|
||||
private get autoRemainderEnabled(): boolean {
|
||||
return process.env.FREIGHT_AUTO_REMAINDER === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
|
||||
* on the next train). Separate flag from auto-remainder: export touches the
|
||||
* FCFS money path, so partial-offer can be enabled independently.
|
||||
*/
|
||||
private get exportSplitEnabled(): boolean {
|
||||
return process.env.FREIGHT_EXPORT_SPLIT === "true";
|
||||
}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
@@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
|
||||
// The split only happens on payment (here) — so auto-placing the remainder
|
||||
// also only happens once the customer has accepted+paid. Re-read to see if
|
||||
// applySplit actually reduced this booking (an open offer existed); if so,
|
||||
// auto-create + place the remainder booking on the next fitting train.
|
||||
// applySplit committed its own transaction before returning, so this reads
|
||||
// the reduced lines. Best-effort: a placement failure never blocks the
|
||||
// paid booking from boarding — the remainder falls back to manual rebook.
|
||||
if (this.autoRemainderEnabled && this.remainderPlacement) {
|
||||
const split = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
// Export remainders only auto-place when export split is on — otherwise
|
||||
// an export booking never splits in the first place.
|
||||
const directionOn =
|
||||
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
|
||||
if (split?.isSplit && directionOn) {
|
||||
await this.remainderPlacement
|
||||
.placeRemainder(split)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Auto-place remainder failed for ${split.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linked =
|
||||
@@ -731,6 +780,186 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains that can carry a booking's leg on a given day, earliest departure
|
||||
* first, each with the largest number of wagons it could still admit for the
|
||||
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
|
||||
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
|
||||
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
|
||||
* non-primary allowed type still counts. The remainder placer uses this to
|
||||
* pick the next fitting train; the `free` wagon count is the best across the
|
||||
* allowed types (a train fits under whichever allowed type gives most room).
|
||||
*/
|
||||
async fittingTrainsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
direction: "IMPORT" | "EXPORT",
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
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" &&
|
||||
(direction === "EXPORT"
|
||||
? s.direction === "EXPORT"
|
||||
: s.direction !== "EXPORT"),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
|
||||
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
|
||||
|
||||
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
|
||||
const room = budget.remainingFor(leg);
|
||||
// Best usable wagons across the allowed types — a train fits under
|
||||
// whichever configured wagon type gives it the most room.
|
||||
let freeWagons = 0;
|
||||
for (const dims of dimsOptions) {
|
||||
const w = this.bookableWithin(room, dims).wagons;
|
||||
if (w > freeWagons) freeWagons = w;
|
||||
}
|
||||
if (freeWagons > 0) {
|
||||
out.push({
|
||||
scheduleId: schedule.id,
|
||||
departure: schedule.scheduledDepartureDate!,
|
||||
freeWagons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export split: no single train carries the whole booking, so offer the
|
||||
* largest fitting part on the export train with the most room for its leg.
|
||||
* Returns true when an offer was opened (the caller must NOT then reserve —
|
||||
* the offer already opened its own pay window), false when the booking fits
|
||||
* whole somewhere (normal FCFS path) or no meaningful partial exists.
|
||||
*
|
||||
* Only the offer is written here: the booking is reduced to the offered part
|
||||
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
|
||||
* unpaid export booking stays whole and the customer may still cancel it.
|
||||
*/
|
||||
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
|
||||
if (!this.splitService) return false;
|
||||
const report = await this.exportSpaceReport(booking);
|
||||
// A train fits it whole — nothing to split, take the normal path.
|
||||
if (report.scheduleId) return false;
|
||||
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
|
||||
|
||||
if (!booking.scheduledDate) return false;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
|
||||
if (!fitting.length) return false;
|
||||
// Most room first — the largest single part ships now, the smallest leftover
|
||||
// is what has to find another train.
|
||||
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) return false;
|
||||
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
schedule.id,
|
||||
budget.remainingFor(leg),
|
||||
report.need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
this.logger.log(
|
||||
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
|
||||
`${schedule.id} — leftover rebooks on the next train once paid.`,
|
||||
);
|
||||
this.notifyBoardChanged(schedule.id, "batch_fill");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -742,6 +971,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
// Export split: when no single train carries the whole booking, offer the
|
||||
// largest fitting part instead of failing the accept. The customer pays
|
||||
// that part; on payment applySplit reduces this booking to it and the
|
||||
// leftover is auto-placed as its own booking on the next train. Pairs are
|
||||
// excluded (handled below) — a shared wagon is never split.
|
||||
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
|
||||
const offered = await this.tryExportPartialOffer(booking);
|
||||
if (offered) return;
|
||||
}
|
||||
const scheduleId = await this.pickExportSchedule(booking);
|
||||
await this.reserveOnExport([booking], scheduleId);
|
||||
return;
|
||||
@@ -1118,6 +1356,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(
|
||||
@@ -1783,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
* A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
|
||||
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
||||
* shared wagon) and government bookings never split (they preempt).
|
||||
*
|
||||
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
||||
* enabled: export historically rides one train whole, so splitting it changes
|
||||
* the FCFS money path — each split part still rides ONE train whole, and the
|
||||
* leftover becomes its own booking on the next train.
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
const directionOk =
|
||||
booking.tradeDirection === "IMPORT" ||
|
||||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
directionOk &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
@@ -3105,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
|
||||
* full allowed (many-to-many) wagon-type list, not just the first like
|
||||
* {@link dimsFor}. The remainder placer needs the whole set so a train that
|
||||
* stocks a non-primary allowed type still counts as fitting: a container type
|
||||
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
|
||||
* train actually has free. Deduped by wagon-type id; falls back to the single
|
||||
* representative dims when no allowed type is configured.
|
||||
*/
|
||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const ids =
|
||||
booking.freightType === "BULK"
|
||||
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
|
||||
: (booking.bookingContainers ?? [])
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wt) => wt.id);
|
||||
const seen = new Set<string>();
|
||||
const dims: PerWagonDims[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const d = wagonDims.byWagonTypeId.get(id);
|
||||
if (d) {
|
||||
dims.push({
|
||||
...d,
|
||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dims.length ? dims : [fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
|
||||
@@ -150,10 +150,18 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const leftover = totalWagons - offeredWagons;
|
||||
// With auto-placement on, the leftover is booked FOR the customer on another
|
||||
// train (its own invoice) — telling them to rebook it themselves would be
|
||||
// wrong. Without it, the leftover returns to the contract to rebook.
|
||||
const leftoverCopy =
|
||||
process.env.FREIGHT_AUTO_REMAINDER === 'true'
|
||||
? `The remaining ${leftover} will be booked for you on another train, with its own invoice. `
|
||||
: `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `;
|
||||
const msg =
|
||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||
leftoverCopy +
|
||||
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||
@@ -164,6 +172,23 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagons that did not fit the train the customer just paid for have been
|
||||
* auto-booked as their own booking (`remainder`) — they ride another train and
|
||||
* are billed separately. Sent instead of leaving the customer to rebook.
|
||||
*/
|
||||
remainderPlaced(remainder: Booking, parentReference: string): void {
|
||||
const msg =
|
||||
`The wagons left over from booking ${parentReference} have been booked as ` +
|
||||
`${remainder.reference ?? remainder.id} on another train. ` +
|
||||
`It carries its own invoice — pay it to secure that slot.`;
|
||||
void this.notifyContact(remainder, msg, 'REMAINDER BOOKED');
|
||||
this.inApp(remainder, 'Leftover wagons booked', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
eatDay,
|
||||
nextCycleOpensAt,
|
||||
@@ -415,6 +416,15 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// window and the cycle stays in PAYMENT; check live reservations on THIS
|
||||
// schedule because the day-level fill may have reserved onto a sibling.
|
||||
// Waiting bookings that fit no train stay pooled and the window reopens.
|
||||
// Booking shuts at the configured cutoff (departure − closeOffset), not
|
||||
// departure — every phase-end below is bounded by it, mirroring the initial
|
||||
// window computation.
|
||||
const cutoff = bookingCloseCutoff(
|
||||
schedule.scheduledDepartureDate,
|
||||
schedule.direction,
|
||||
cfg,
|
||||
);
|
||||
|
||||
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
|
||||
if (
|
||||
promoted > 0 &&
|
||||
@@ -423,8 +433,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
let paymentPhaseEndsAt = new Date(
|
||||
now.getTime() + cfg.paymentWindowMinutes * 60_000,
|
||||
);
|
||||
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
|
||||
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
|
||||
if (paymentPhaseEndsAt > cutoff) {
|
||||
paymentPhaseEndsAt = cutoff;
|
||||
}
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
this.logger.log(
|
||||
@@ -441,11 +451,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
};
|
||||
const nextOpensAt = nextCycleOpensAt(
|
||||
now,
|
||||
officeHours,
|
||||
schedule.scheduledDepartureDate,
|
||||
);
|
||||
const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff);
|
||||
if (nextOpensAt == null) {
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
this.logger.log(
|
||||
@@ -464,8 +470,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// Office hours end a running window early: never let the duration outlive
|
||||
// the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
|
||||
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
|
||||
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||
nextClosesAt = schedule.scheduledDepartureDate;
|
||||
if (nextClosesAt > cutoff) {
|
||||
nextClosesAt = cutoff;
|
||||
}
|
||||
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
|
||||
// whether that is later today or next morning after the office-hours break.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
|
||||
/**
|
||||
* The remainder placer reconstructs the outstanding split remainder as a new
|
||||
* booking. The delicate parts under test: bulk sizes from the outstanding tons;
|
||||
* container recovers real numbers from the SOFT-DELETED units (never fabricates)
|
||||
* and throws on a shortfall; and nothing is placed when there's no outstanding
|
||||
* or no fitting train.
|
||||
*/
|
||||
describe('RemainderPlacementService', () => {
|
||||
const DAY = '2026-07-20';
|
||||
|
||||
function make(opts: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
contractKind?: 'ONE_TIME' | 'GENERAL';
|
||||
outstanding: unknown;
|
||||
createThrows?: Error;
|
||||
deferredUnits?: Array<{
|
||||
containerNumber: string;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
}>;
|
||||
fittingTrains?: Array<{ scheduleId: string }>;
|
||||
}) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
freightType: opts.freightType,
|
||||
contractKind: opts.contractKind ?? 'ONE_TIME',
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
|
||||
};
|
||||
const createUnderContract = opts.createThrows
|
||||
? jest.fn().mockRejectedValue(opts.createThrows)
|
||||
: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
|
||||
const contractBookingService = {
|
||||
splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
|
||||
createUnderContract,
|
||||
};
|
||||
const bookingBatchService = {
|
||||
fittingTrainsForDay: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
|
||||
};
|
||||
// getRepository is only hit on the container path (recoverDeferredUnits).
|
||||
const lineRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
|
||||
};
|
||||
const unitRepo = {
|
||||
find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
const n = entity?.name ?? '';
|
||||
if (n.includes('Unit')) return unitRepo;
|
||||
return lineRepo;
|
||||
}),
|
||||
};
|
||||
const notifier = { remainderPlaced: jest.fn() };
|
||||
const service = new RemainderPlacementService(
|
||||
dataSource as never,
|
||||
contractsRepository as never,
|
||||
contractBookingService as never,
|
||||
bookingBatchService as never,
|
||||
notifier as never,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
createUnderContract,
|
||||
contractBookingService,
|
||||
bookingBatchService,
|
||||
notifier,
|
||||
};
|
||||
}
|
||||
|
||||
const splitBooking = {
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-1',
|
||||
contractId: 'c-1',
|
||||
scheduledDate: new Date('2026-07-20T06:00:00Z'),
|
||||
createdByUserId: 'u-1',
|
||||
} as never;
|
||||
|
||||
it('sizes a BULK remainder from the outstanding tons', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
|
||||
expect(dto.scheduledDate).toBe(DAY);
|
||||
});
|
||||
|
||||
it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
|
||||
const deferredUnits = [
|
||||
{ containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
|
||||
{ containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
|
||||
];
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.containers).toHaveLength(1);
|
||||
const line = dto.containers[0];
|
||||
expect(line.containerSize).toBe('40ft');
|
||||
expect(line.quantity).toBe(2);
|
||||
expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
|
||||
'ABCD1234567',
|
||||
'ABCD7654321',
|
||||
]);
|
||||
expect(line.reeferQuantity).toBe(1);
|
||||
expect(line.hazardousQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when there is no outstanding remainder', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the customer the leftover wagons were booked on another train', async () => {
|
||||
const { service, notifier } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
await service.placeRemainder(splitBooking);
|
||||
expect(notifier.remainderPlaced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'rem-1' }),
|
||||
'BKG-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-books the leftover when two payments land together', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
// Both callers enter before either create commits.
|
||||
await Promise.all([
|
||||
service.placeRemainder(splitBooking),
|
||||
service.placeRemainder(splitBooking),
|
||||
]);
|
||||
expect(createUnderContract).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
|
||||
// snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
|
||||
// either drops a real remainder or double-draws the cap, so we must not place.
|
||||
it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
|
||||
const { service, createUnderContract, contractBookingService } = make({
|
||||
freightType: 'BULK',
|
||||
contractKind: 'GENERAL',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The paid booking has already boarded — a create-gate rejection (e.g. the
|
||||
// export whole-train gate) must leave the remainder rebookable, not escape.
|
||||
it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
|
||||
const { service } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
createThrows: new Error('Not enough train space for this day.'),
|
||||
});
|
||||
await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when the contract has no split chain', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: null,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common';
|
||||
import { DataSource, IsNull, Not } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
ContractBookingService,
|
||||
SplitOutstanding,
|
||||
} from '../contracts/contract-booking.service';
|
||||
import { ContractsRepository } from '../contracts/contracts.repository';
|
||||
import {
|
||||
CreateBookingUnderContractDto,
|
||||
CreateContainerUnitDto,
|
||||
} from '../contracts/dto/create-booking-under-contract.dto';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { eatDay } from './batch-window.util';
|
||||
|
||||
/**
|
||||
* Auto-creates and places the OUTSTANDING split remainder of a contract as a new
|
||||
* booking, so the customer doesn't have to manually rebook the wagons that did
|
||||
* not fit the train they just paid for.
|
||||
*
|
||||
* Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only
|
||||
* once the customer has actually accepted+paid the offered part. Before payment
|
||||
* nothing is split: the booking stays whole and the customer may still edit or
|
||||
* cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}.
|
||||
*
|
||||
* IMPORT/DOMESTIC: the remainder booking is created with the next fitting
|
||||
* shipment day set and then follows the normal windowed batch flow (train
|
||||
* assigned at window close, paid in its own window). It is NOT force-reserved on
|
||||
* a specific train — import is not FCFS.
|
||||
*
|
||||
* Container reconstruction is HYBRID: the remainder's quantities come from the
|
||||
* split snapshot (`splitOutstanding`), but the actual container numbers / VGM /
|
||||
* seals are read back from the units `applySplit` SOFT-DELETED off the parent
|
||||
* (they survive as valid ISO records). We never `restore()` those rows — the new
|
||||
* booking gets fresh rows — so the contract cap is never double-counted.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RemainderPlacementService {
|
||||
private readonly logger = new Logger(RemainderPlacementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
@Inject(forwardRef(() => ContractBookingService))
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create + place the outstanding split remainder of the contract that owns
|
||||
* `splitBooking`. No-op when there is no live remainder or no fitting day.
|
||||
* Returns the created remainder booking id, or null when nothing was placed
|
||||
* (residual falls back to the customer's manual rebook, as today).
|
||||
*/
|
||||
async placeRemainder(splitBooking: Booking): Promise<string | null> {
|
||||
if (!splitBooking.contractId) return null;
|
||||
// Two payment webhooks for the same contract landing together would both see
|
||||
// the remainder as unbooked (the placing create has not committed yet) and
|
||||
// each create one — double-booking the leftover. Serialize per contract: the
|
||||
// second caller returns immediately and the first one's create is what the
|
||||
// (now smaller) outstanding reflects.
|
||||
if (this.inFlight.has(splitBooking.contractId)) {
|
||||
this.logger.debug(
|
||||
`Remainder placement already running for contract ${splitBooking.contractId} — skipped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
this.inFlight.add(splitBooking.contractId);
|
||||
try {
|
||||
return await this.placeRemainderInner(splitBooking);
|
||||
} finally {
|
||||
this.inFlight.delete(splitBooking.contractId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Contracts with a placement in flight — see {@link placeRemainder}. */
|
||||
private readonly inFlight = new Set<string>();
|
||||
|
||||
private async placeRemainderInner(
|
||||
splitBooking: Booking,
|
||||
): Promise<string | null> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
splitBooking.contractId!,
|
||||
);
|
||||
if (!contract) return null;
|
||||
|
||||
// ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total
|
||||
// from a SINGLE booking's pre-split snapshot, which is only coherent when
|
||||
// the contract has exactly one live chain — that is the ONE_TIME invariant
|
||||
// (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL
|
||||
// contract with other live bookings the subtraction mixes scopes: it either
|
||||
// clamps to 0 and silently drops a real remainder, or sizes one that then
|
||||
// draws the quantity cap a second time. GENERAL remainders keep the existing
|
||||
// manual-rebook behaviour until the remainder can be derived from the
|
||||
// offer's own dropped lines rather than from the contract-wide ledger.
|
||||
if (contract.contractKind !== 'ONE_TIME') {
|
||||
this.logger.debug(
|
||||
`Contract ${contract.id} is ${contract.contractKind} — remainder left ` +
|
||||
`for manual rebook (auto-placement is ONE_TIME only).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const outstanding = await this.contractBookingService.splitOutstanding(
|
||||
contract,
|
||||
);
|
||||
if (!outstanding || !this.hasOutstanding(contract, outstanding)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The next fitting day: the earliest day on/after the split booking's own day
|
||||
// that still has an import train with room for this cargo type. We reuse the
|
||||
// split booking as the capacity probe — it carries the leg + cargo relations.
|
||||
const day = await this.nextFittingDay(splitBooking);
|
||||
if (!day) {
|
||||
this.logger.warn(
|
||||
`No train with room for the remainder of contract ${contract.id} ` +
|
||||
`(booking ${splitBooking.reference}) — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let dto: CreateBookingUnderContractDto;
|
||||
try {
|
||||
dto = await this.buildRemainderDto(
|
||||
contract,
|
||||
outstanding,
|
||||
splitBooking.id,
|
||||
day,
|
||||
);
|
||||
} catch (err) {
|
||||
// A reconstruction shortfall (fewer recoverable units than outstanding)
|
||||
// must NOT fabricate container numbers — fail loudly, leave manual rebook.
|
||||
this.logger.error(
|
||||
`Could not reconstruct the remainder of contract ${contract.id}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)} — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Any create-gate rejection (no train space, cap, container clash) must not
|
||||
// escape: the customer's paid booking has already boarded, and a thrown
|
||||
// error here would only be logged upstream while the remainder vanished
|
||||
// silently. Fall back to leaving it rebookable, which is the pre-feature
|
||||
// behaviour, and say so in the log.
|
||||
let created: Awaited<
|
||||
ReturnType<ContractBookingService['createUnderContract']>
|
||||
>;
|
||||
try {
|
||||
created = await this.contractBookingService.createUnderContract(
|
||||
contract.id,
|
||||
dto,
|
||||
{ id: splitBooking.createdByUserId ?? undefined },
|
||||
// System actor: a permission-bag carrying the contract create-booking key
|
||||
// so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B
|
||||
// contracts; harmless for customer (Path A) contracts.
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Could not create the remainder booking for contract ${contract.id} ` +
|
||||
`(from ${splitBooking.reference}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
} — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// EXPORT is FCFS — there is no window to wait for, so the remainder is
|
||||
// reserved on the next export train right away (its own pay window opens).
|
||||
// If it does not fit one train whole either, the export accept offers it a
|
||||
// partial and the chain repeats on ITS payment: each pass leaves a strictly
|
||||
// smaller remainder, so it terminates at the day's train count.
|
||||
// IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next
|
||||
// fitting day and rides the normal windowed batch flow.
|
||||
if (splitBooking.tradeDirection === 'EXPORT') {
|
||||
const fresh = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({
|
||||
where: { id: created.booking.id },
|
||||
relations: {
|
||||
company: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
},
|
||||
});
|
||||
if (fresh) {
|
||||
await this.bookingBatchService
|
||||
.acceptExportBooking(fresh)
|
||||
.catch((err) =>
|
||||
// No export train took it — it stays created and rebookable, which
|
||||
// is the same place a customer-driven rebook would leave it.
|
||||
this.logger.warn(
|
||||
`Export remainder ${fresh.reference} created but not reserved: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifier.remainderPlaced(
|
||||
created.booking,
|
||||
splitBooking.reference ?? splitBooking.id,
|
||||
);
|
||||
this.logger.log(
|
||||
`Auto-placed split remainder of contract ${contract.id} as booking ` +
|
||||
`${created.booking.reference} on ${day}.`,
|
||||
);
|
||||
return created.booking.id;
|
||||
}
|
||||
|
||||
private hasOutstanding(
|
||||
contract: Contract,
|
||||
outstanding: SplitOutstanding,
|
||||
): boolean {
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
return [...outstanding.bySize.values()].some((s) => s.outstanding > 0);
|
||||
}
|
||||
return (outstanding.bulk?.outstanding ?? 0) > 0.001;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipment day to create the remainder on — the split booking's own day.
|
||||
*
|
||||
* EXPORT is FCFS and must actually board a train that day, so a day with NO
|
||||
* export train having room is rejected (null → left for manual rebook on a day
|
||||
* the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is
|
||||
* assigned by the batch engine at window close, not now, and the window may
|
||||
* still free up — forcing a different day here would override the customer's
|
||||
* binding shipment day.
|
||||
*/
|
||||
private async nextFittingDay(booking: Booking): Promise<string | null> {
|
||||
if (!booking.scheduledDate) return null;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
if (booking.tradeDirection !== 'EXPORT') return day;
|
||||
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
booking,
|
||||
day,
|
||||
'EXPORT',
|
||||
);
|
||||
return fitting.length > 0 ? day : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the
|
||||
* outstanding tonnage directly. Container reads the deferred (soft-deleted)
|
||||
* units of the split booking back into real unit records.
|
||||
*/
|
||||
private async buildRemainderDto(
|
||||
contract: Contract,
|
||||
outstanding: SplitOutstanding,
|
||||
splitBookingId: string,
|
||||
day: string,
|
||||
): Promise<CreateBookingUnderContractDto> {
|
||||
const dto: CreateBookingUnderContractDto = { scheduledDate: day };
|
||||
|
||||
if (contract.freightType !== 'CONTAINER') {
|
||||
const tons = outstanding.bulk?.outstanding ?? 0;
|
||||
dto.bulkLines = [{ cargoWeightTons: tons }];
|
||||
return dto;
|
||||
}
|
||||
|
||||
// Container: recover the deferred units per size from the split booking's
|
||||
// soft-deleted rows and reshape into DTO units.
|
||||
const containers: NonNullable<CreateBookingUnderContractDto['containers']> = [];
|
||||
for (const [size, { outstanding: need }] of outstanding.bySize) {
|
||||
if (need <= 0) continue;
|
||||
const units = await this.recoverDeferredUnits(splitBookingId, size, need);
|
||||
if (units.length < need) {
|
||||
throw new Error(
|
||||
`size ${size}: recovered ${units.length} deferred container(s) but ` +
|
||||
`${need} are outstanding`,
|
||||
);
|
||||
}
|
||||
const line: NonNullable<CreateBookingUnderContractDto['containers']>[number] = {
|
||||
containerSize: size,
|
||||
quantity: need,
|
||||
units,
|
||||
};
|
||||
line.hazardousQuantity = units.filter((u) => u.isHazardous).length;
|
||||
line.reeferQuantity = units.filter((u) => u.isReefer).length;
|
||||
containers.push(line);
|
||||
}
|
||||
dto.containers = containers;
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `need` deferred container units of a given size for the split booking,
|
||||
* read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the
|
||||
* LIFO trim in applySplit so the same physical containers deferred are the
|
||||
* ones rebooked). Returns them as DTO units; does NOT restore the rows.
|
||||
*/
|
||||
private async recoverDeferredUnits(
|
||||
splitBookingId: string,
|
||||
containerSize: string,
|
||||
need: number,
|
||||
): Promise<CreateContainerUnitDto[]> {
|
||||
// The line ids of this booking for this size (live + soft-deleted): units
|
||||
// key on bookingContainerId, so gather every line of the size first.
|
||||
const lines = await this.dataSource
|
||||
.getRepository(BookingContainer)
|
||||
.find({
|
||||
where: { bookingId: splitBookingId, containerSize },
|
||||
withDeleted: true,
|
||||
select: { id: true },
|
||||
});
|
||||
const lineIds = lines.map((l) => l.id);
|
||||
if (!lineIds.length) return [];
|
||||
|
||||
// Only the DELETED units are the deferred ones (live units stayed on the
|
||||
// paid part). Oldest-first to match the deferred set.
|
||||
const deferred = await this.dataSource
|
||||
.getRepository(BookingContainerUnit)
|
||||
.find({
|
||||
where: lineIds.map((bookingContainerId) => ({
|
||||
bookingContainerId,
|
||||
deletedAt: Not(IsNull()),
|
||||
})),
|
||||
withDeleted: true,
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
take: need,
|
||||
});
|
||||
|
||||
return deferred.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? undefined,
|
||||
vgmTons: Number(u.vgmTons),
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -82,6 +83,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
WsAuthService,
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
RemainderPlacementService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
FacilityHandlingService,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
|
||||
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run.
|
||||
*
|
||||
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
|
||||
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
|
||||
* the convention stays correct here.
|
||||
*
|
||||
* SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a
|
||||
* migration must keep doing what it did when it was applied, whereas this list
|
||||
* is live config for the update script. Add or retire runs HERE.
|
||||
*/
|
||||
export const TRAIN_RUN_PAIRS: Record<string, string> = {
|
||||
'8001': '8002',
|
||||
'8101': '8102',
|
||||
'8201': '8202',
|
||||
'8301': '8302',
|
||||
'8401': '8402',
|
||||
'8501': '8502',
|
||||
'8601': '8602',
|
||||
'8701': '8702',
|
||||
'8801': '8802',
|
||||
'8901': '8902',
|
||||
'9001': '9002',
|
||||
};
|
||||
|
||||
/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */
|
||||
export const EXPORT_BY_IMPORT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalise any run number to its EXPORT run. Accepts either half of a pair, so
|
||||
* a sheet listing "8002" and one listing "8001" both resolve to the same train.
|
||||
* Returns null when the number belongs to no known run.
|
||||
*/
|
||||
export const toExportRun = (run: string): string | null => {
|
||||
const value = run.trim();
|
||||
if (TRAIN_RUN_PAIRS[value]) return value;
|
||||
return EXPORT_BY_IMPORT[value] ?? null;
|
||||
};
|
||||
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal file
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { TRAIN_RUN_PAIRS, toExportRun } from '../modules/wagons/train-runs.const';
|
||||
|
||||
/**
|
||||
* Update wagon run numbers from a roster file — the tool for making the DB match
|
||||
* the operator's sheet.
|
||||
*
|
||||
* pnpm seed:wagon-runs <file.csv> [--apply]
|
||||
*
|
||||
* CSV: two columns, header optional. Either half of a run pair is accepted, so
|
||||
* "8001" and "8002" both mean the same train.
|
||||
*
|
||||
* wagon_number,run
|
||||
* ER0744,8001
|
||||
* ER0458,8102
|
||||
*
|
||||
* FULL REPLACEMENT: wagons absent from the file have their runs cleared, so the
|
||||
* DB ends up matching the file exactly rather than accumulating stale rows.
|
||||
*
|
||||
* Dry run by default — it validates and prints what would change. Nothing is
|
||||
* written without `--apply`. Validation is fatal on: an unknown run, a wagon not
|
||||
* in the database, or the same wagon claimed by two runs (a wagon holds one run,
|
||||
* so a double-booking has no correct answer and must be fixed in the sheet).
|
||||
*/
|
||||
interface Row {
|
||||
line: number;
|
||||
wagonNumber: string;
|
||||
exportRun: string;
|
||||
}
|
||||
|
||||
function parseCsv(path: string) {
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const rows: Row[] = [];
|
||||
const unknownRuns: string[] = [];
|
||||
|
||||
text.split(/\r?\n/).forEach((raw, i) => {
|
||||
const line = i + 1;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return;
|
||||
|
||||
const [rawWagon = '', rawRun = ''] = trimmed.split(',').map((c) => c.trim());
|
||||
// Skip a header row without needing it to be declared.
|
||||
if (/wagon/i.test(rawWagon) && /run|train/i.test(rawRun)) return;
|
||||
if (!rawWagon || !rawRun) {
|
||||
throw new Error(`line ${line}: expected "wagon_number,run", got "${trimmed}"`);
|
||||
}
|
||||
|
||||
const exportRun = toExportRun(rawRun);
|
||||
if (!exportRun) {
|
||||
unknownRuns.push(`line ${line}: "${rawRun}" (wagon ${rawWagon})`);
|
||||
return;
|
||||
}
|
||||
rows.push({ line, wagonNumber: rawWagon.toUpperCase(), exportRun });
|
||||
});
|
||||
|
||||
return { rows, unknownRuns };
|
||||
}
|
||||
|
||||
async function updateWagonRuns() {
|
||||
const [fileArg, ...flags] = process.argv.slice(2);
|
||||
const apply = flags.includes('--apply');
|
||||
|
||||
if (!fileArg) {
|
||||
console.error('usage: pnpm seed:wagon-runs <file.csv> [--apply]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const path = resolve(process.cwd(), fileArg);
|
||||
const { rows, unknownRuns } = parseCsv(path);
|
||||
|
||||
// A wagon in two runs cannot be represented — surface every instance rather
|
||||
// than silently keeping whichever line happened to come first.
|
||||
const seen = new Map<string, Row>();
|
||||
const doubleBooked: string[] = [];
|
||||
for (const row of rows) {
|
||||
const prior = seen.get(row.wagonNumber);
|
||||
if (prior && prior.exportRun !== row.exportRun) {
|
||||
doubleBooked.push(
|
||||
`${row.wagonNumber}: run ${prior.exportRun} (line ${prior.line}) vs ${row.exportRun} (line ${row.line})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!prior) seen.set(row.wagonNumber, row);
|
||||
}
|
||||
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
const wagonNumbers = [...seen.keys()];
|
||||
const existing: Array<{ wagon_number: string }> = wagonNumbers.length
|
||||
? await AppDataSource.query(
|
||||
`SELECT wagon_number FROM freight.wagons
|
||||
WHERE deleted_at IS NULL AND wagon_number = ANY($1::text[]);`,
|
||||
[wagonNumbers],
|
||||
)
|
||||
: [];
|
||||
const known = new Set(existing.map((r) => r.wagon_number));
|
||||
const missing = wagonNumbers.filter((w) => !known.has(w));
|
||||
|
||||
const problems = [
|
||||
...unknownRuns.map((u) => `unknown run ${u}`),
|
||||
...doubleBooked.map((d) => `double-booked ${d}`),
|
||||
...missing.map((m) => `not in database ${m}`),
|
||||
];
|
||||
|
||||
const perRun = new Map<string, number>();
|
||||
for (const row of seen.values()) {
|
||||
if (known.has(row.wagonNumber)) {
|
||||
perRun.set(row.exportRun, (perRun.get(row.exportRun) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nFile: ${path}`);
|
||||
console.log(`Rows read: ${rows.length + unknownRuns.length} | assignable: ${known.size}`);
|
||||
console.table(
|
||||
Object.keys(TRAIN_RUN_PAIRS).map((exportRun) => ({
|
||||
export_run: exportRun,
|
||||
import_run: TRAIN_RUN_PAIRS[exportRun],
|
||||
wagons: perRun.get(exportRun) ?? 0,
|
||||
})),
|
||||
);
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n${problems.length} problem(s) — nothing was written:`);
|
||||
problems.forEach((p) => console.error(` ${p}`));
|
||||
console.error('\nFix these in the source sheet, then re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log('\nDry run — no changes written. Re-run with --apply to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
await AppDataSource.transaction(async (manager) => {
|
||||
// Full replacement: clear first so a wagon dropped from the sheet does not
|
||||
// keep a run it no longer has.
|
||||
await manager.query(`
|
||||
UPDATE freight.wagons
|
||||
SET export_train_number = NULL, import_train_number = NULL
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
|
||||
for (const exportRun of new Set([...seen.values()].map((r) => r.exportRun))) {
|
||||
const wagons = [...seen.values()]
|
||||
.filter((r) => r.exportRun === exportRun)
|
||||
.map((r) => r.wagonNumber);
|
||||
await manager.query(
|
||||
`UPDATE freight.wagons
|
||||
SET export_train_number = $1,
|
||||
import_train_number = $2,
|
||||
updated_at = now()
|
||||
WHERE wagon_number = ANY($3::text[]);`,
|
||||
[exportRun, TRAIN_RUN_PAIRS[exportRun], wagons],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const [totals] = await AppDataSource.query(`
|
||||
SELECT COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run
|
||||
FROM freight.wagons WHERE deleted_at IS NULL;
|
||||
`);
|
||||
console.log(`\nApplied. ${totals.on_a_run} wagons now on a run.`);
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
updateWagonRuns().catch((error) => {
|
||||
console.error('Failed to update wagon runs:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -15,10 +15,23 @@ import {
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
/**
|
||||
* When true, the response interceptor does NOT raise the global error modal
|
||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
||||
* probe that is expected to 404 before falling back (GL clearance detail
|
||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
||||
*/
|
||||
suppressErrorModal?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type RetriableRequest = {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
suppressErrorModal?: boolean;
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
@@ -100,8 +113,13 @@ api.interceptors.response.use(
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
// Surface the server's actual error message in the global error modal
|
||||
// (401s are handled by the session-refresh flow, so skip them).
|
||||
if (error.response && error.response.status !== 401) {
|
||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status !== 401 &&
|
||||
!originalRequest?.suppressErrorModal
|
||||
) {
|
||||
const payload = extractApiErrorPayload(error);
|
||||
if (payload) emitApiError(payload);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -61,9 +60,12 @@ type GlClearanceDetail =
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
try {
|
||||
// Probe the contract endpoints first; a booking-id row 404s here by design
|
||||
// and falls back to the booking lookup below. Suppress the global error
|
||||
// modal so that expected 404 never surfaces to the user.
|
||||
const [clearance, contract] = await Promise.all([
|
||||
contractsService.getClearance(id),
|
||||
contractsService.getById(id),
|
||||
contractsService.getClearance(id, { suppressErrorModal: true }),
|
||||
contractsService.getById(id, { suppressErrorModal: true }),
|
||||
]);
|
||||
return {
|
||||
kind: "contract",
|
||||
@@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
{canCompleteBooking && shipmentBooking ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,8 +164,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
||||
getById: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
@@ -258,8 +261,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id));
|
||||
getClearance: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id), opts);
|
||||
return unwrap(response.data) as Freight.ContractClearanceView;
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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({
|
||||
input: { 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>
|
||||
);
|
||||
}
|
||||
@@ -409,7 +409,11 @@ function LocationPickerInline({
|
||||
const geocoder = useGeocoder();
|
||||
const places = usePlacesSearch();
|
||||
const placesLib = useMapsLibrary("places");
|
||||
const [query, setQuery] = useState("");
|
||||
// `null` means "not editing" (show the saved address); any string — including
|
||||
// "" after the user clears the field — is live edit state. A plain `query ||
|
||||
// value.address` fallback would snap the saved address back the moment the
|
||||
// user cleared the input, making it impossible to retype the location.
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<PlacePrediction[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
@@ -430,7 +434,7 @@ function LocationPickerInline({
|
||||
// than one per keystroke. While it runs, the input shows a spinner; the
|
||||
// dropdown itself only appears once there are predictions to show.
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
const q = (query ?? "").trim();
|
||||
if (q.length < MIN_QUERY_LEN) {
|
||||
setResults([]);
|
||||
setSearching(false);
|
||||
@@ -469,7 +473,7 @@ function LocationPickerInline({
|
||||
async (prediction: PlacePrediction) => {
|
||||
// Clear the query/results immediately so the pending debounce can't fire
|
||||
// a search for the picked address and pop the dropdown back open.
|
||||
setQuery("");
|
||||
setQuery(null);
|
||||
setResults([]);
|
||||
// Predictions carry no coordinates — resolve them now via Place Details.
|
||||
if (!places) return;
|
||||
@@ -498,6 +502,9 @@ function LocationPickerInline({
|
||||
const handlePin = useCallback(
|
||||
async (lat: number, lng: number) => {
|
||||
// Show the pin immediately; fill the address once reverse geocoding lands.
|
||||
// Leave edit mode so the input reflects the reverse-geocoded address
|
||||
// instead of whatever half-typed query the user abandoned for the map.
|
||||
setQuery(null);
|
||||
onChange({ address: value.address, lat, lng });
|
||||
if (!geocoder) return;
|
||||
// Mark any in-flight reverse lookup stale — only the latest pin counts.
|
||||
@@ -525,7 +532,7 @@ function LocationPickerInline({
|
||||
[handlePin],
|
||||
);
|
||||
|
||||
const inputValue = query || value.address;
|
||||
const inputValue = query ?? value.address;
|
||||
const center = hasPin
|
||||
? { lat: value.lat as number, lng: value.lng as number }
|
||||
: DEFAULT_CENTER;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -949,6 +949,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;
|
||||
|
||||
Reference in New Issue
Block a user