mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
feat(billing): USD offline bank-transfer payments
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
|
||||
describe('deriveScheduleDirection', () => {
|
||||
it('returns IMPORT when origin is Djibouti', () => {
|
||||
expect(
|
||||
deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }),
|
||||
).toBe('IMPORT');
|
||||
});
|
||||
|
||||
it('returns EXPORT when destination is Djibouti and origin is not', () => {
|
||||
expect(
|
||||
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }),
|
||||
).toBe('EXPORT');
|
||||
});
|
||||
|
||||
it('returns DOMESTIC for intra-Ethiopia routes', () => {
|
||||
expect(
|
||||
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }),
|
||||
).toBe('DOMESTIC');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||
export const deriveScheduleDirection = deriveTradeDirection;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
selectBookingsWithinFleetCap,
|
||||
sortBookingsForScheduling,
|
||||
summarizeFleetWarnings,
|
||||
wagonsRequiredForBooking,
|
||||
} from './fleet-plan.util';
|
||||
import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util';
|
||||
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
} as WagonType;
|
||||
|
||||
const makeBooking = (
|
||||
id: string,
|
||||
extra: Partial<Booking> = {},
|
||||
): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
freightType: 'CONTAINER',
|
||||
isGovernment: false,
|
||||
priorityScore: 0,
|
||||
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
cargoTotalWeightVgm: 50,
|
||||
bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }],
|
||||
...extra,
|
||||
}) as Booking;
|
||||
|
||||
describe('fleet-plan.util', () => {
|
||||
it('sorts bookings government first, then priority, then date', () => {
|
||||
const bookings = [
|
||||
makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }),
|
||||
makeBooking('gov', { isGovernment: true, priorityScore: 0 }),
|
||||
makeBooking('prio', { priorityScore: 10 }),
|
||||
];
|
||||
|
||||
const sorted = sortBookingsForScheduling(bookings);
|
||||
expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']);
|
||||
});
|
||||
|
||||
it('computes fleet availability with shortfall', () => {
|
||||
const plan: WagonPlanSlot[] = buildContainerWagonPlan(
|
||||
[
|
||||
makeBooking('b1', {
|
||||
bookingContainers: [
|
||||
{ id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||
],
|
||||
}),
|
||||
],
|
||||
nw5,
|
||||
);
|
||||
const fleetByTypeId = new Map([[nw5.id, 1]]);
|
||||
|
||||
const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']]));
|
||||
const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5');
|
||||
|
||||
expect(nw5Row?.needed).toBe(2);
|
||||
expect(nw5Row?.available).toBe(1);
|
||||
expect(nw5Row?.shortfall).toBe(1);
|
||||
});
|
||||
|
||||
it('defers lower-priority bookings when fleet is insufficient', () => {
|
||||
const high = makeBooking('high', {
|
||||
priorityScore: 100,
|
||||
bookingContainers: [
|
||||
{ id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||
],
|
||||
});
|
||||
const low = makeBooking('low', {
|
||||
priorityScore: 1,
|
||||
bookingContainers: [
|
||||
{ id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
|
||||
],
|
||||
});
|
||||
const fleet = new Map([[nw5.id, 2]]);
|
||||
|
||||
const { fitting, deferred } = selectBookingsWithinFleetCap(
|
||||
[low, high],
|
||||
fleet,
|
||||
() => nw5.id,
|
||||
);
|
||||
|
||||
expect(fitting.map((b) => b.id)).toEqual(['high']);
|
||||
expect(deferred).toHaveLength(1);
|
||||
expect(deferred[0]?.id).toBe('low');
|
||||
expect(deferred[0]?.reason).toContain('2');
|
||||
});
|
||||
|
||||
it('summarizes fleet shortage warnings', () => {
|
||||
const warnings = summarizeFleetWarnings(
|
||||
[
|
||||
{
|
||||
wagonTypeId: nw5.id,
|
||||
wagonTypeCode: 'NW5',
|
||||
needed: 5,
|
||||
available: 2,
|
||||
shortfall: 3,
|
||||
},
|
||||
],
|
||||
[{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }],
|
||||
);
|
||||
|
||||
expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true);
|
||||
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
|
||||
});
|
||||
|
||||
it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
|
||||
const warnings = summarizeFleetWarnings(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'b1',
|
||||
reference: 'BKG-1',
|
||||
reason: 'No available NW6 wagon at the yard',
|
||||
shortage: {
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 2,
|
||||
wagonsAvailable: 1,
|
||||
wagonsShort: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
warnings.some(
|
||||
(w) =>
|
||||
w.includes('BKG-1') &&
|
||||
w.includes('2 × NW6') &&
|
||||
w.includes('only 1 available') &&
|
||||
w.includes('short 1'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts wagons required per booking from container lines', () => {
|
||||
const booking = makeBooking('b1', {
|
||||
bookingContainers: [
|
||||
{ id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
|
||||
{ id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
|
||||
],
|
||||
});
|
||||
expect(wagonsRequiredForBooking(booking)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
|
||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
=======
|
||||
import type { Booking } from '../../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts
|
||||
import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
buildMixedWagonPlan,
|
||||
containerWagonsForLines,
|
||||
roundTons,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
export type FleetAvailabilityRow = {
|
||||
wagonTypeId: string;
|
||||
wagonTypeCode: string;
|
||||
needed: number;
|
||||
available: number;
|
||||
shortfall: number;
|
||||
};
|
||||
|
||||
/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
|
||||
export type BookingWagonShortage = {
|
||||
/** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
|
||||
wagonTypeCodes: string;
|
||||
wagonsNeeded: number;
|
||||
wagonsAvailable: number;
|
||||
wagonsShort: number;
|
||||
};
|
||||
|
||||
export type DeferredBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
reason: string;
|
||||
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
|
||||
shortage?: BookingWagonShortage | null;
|
||||
};
|
||||
|
||||
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
return [...bookings].sort((a, b) => {
|
||||
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
|
||||
if (govDiff !== 0) return govDiff;
|
||||
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
|
||||
if (booking.freightType === 'BULK') {
|
||||
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
|
||||
// holds the item count there, not tons. No wagon type is fixed yet, so use
|
||||
// the best count across the cargo's allowed types (per-type items-fit
|
||||
// respected); falls back to `capacity` when the relation isn't loaded.
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so tonnage divides by that cap, not by raw capacity.
|
||||
const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity);
|
||||
if (byWagons > 0) return byWagons;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
|
||||
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
|
||||
// wagon). Derived from containerType.sizeFt; falls back to the line's stored
|
||||
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
}
|
||||
|
||||
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
|
||||
const map = new Map<string, { code: string; count: number }>();
|
||||
for (const slot of wagonPlan) {
|
||||
const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 };
|
||||
existing.count += 1;
|
||||
map.set(slot.wagonTypeId, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function computeFleetAvailability(
|
||||
demandPlan: WagonPlanSlot[],
|
||||
fleetByTypeId: Map<string, number>,
|
||||
fleetTypeCodes: Map<string, string>,
|
||||
): FleetAvailabilityRow[] {
|
||||
const neededByType = countSlotsByType(demandPlan);
|
||||
const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]);
|
||||
|
||||
return [...typeIds].map((wagonTypeId) => {
|
||||
const needed = neededByType.get(wagonTypeId)?.count ?? 0;
|
||||
const available = fleetByTypeId.get(wagonTypeId) ?? 0;
|
||||
return {
|
||||
wagonTypeId,
|
||||
wagonTypeCode:
|
||||
neededByType.get(wagonTypeId)?.code ??
|
||||
fleetTypeCodes.get(wagonTypeId) ??
|
||||
wagonTypeId,
|
||||
needed,
|
||||
available,
|
||||
shortfall: Math.max(0, needed - available),
|
||||
};
|
||||
}).filter((row) => row.needed > 0 || row.available > 0);
|
||||
}
|
||||
|
||||
export function selectBookingsWithinFleetCap(
|
||||
bookings: Booking[],
|
||||
fleetByTypeId: Map<string, number>,
|
||||
resolveWagonTypeId: (booking: Booking) => string,
|
||||
bulkWagonCapacity?: number,
|
||||
): { fitting: Booking[]; deferred: DeferredBookingRow[] } {
|
||||
const remaining = new Map(fleetByTypeId);
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
const typeId = resolveWagonTypeId(booking);
|
||||
const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity);
|
||||
const available = remaining.get(typeId) ?? 0;
|
||||
|
||||
if (available >= needed) {
|
||||
remaining.set(typeId, available - needed);
|
||||
fitting.push(booking);
|
||||
continue;
|
||||
}
|
||||
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
reason:
|
||||
available > 0
|
||||
? `Needs ${needed} wagons but only ${available} available for this type`
|
||||
: `No available wagons for required type (${needed} needed)`,
|
||||
});
|
||||
}
|
||||
|
||||
return { fitting, deferred };
|
||||
}
|
||||
|
||||
export function buildCappedWagonPlan(params: {
|
||||
bookings: Booking[];
|
||||
resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
containerWagonType: WagonType;
|
||||
bulkWagonType: WagonType;
|
||||
}): WagonPlanSlot[] {
|
||||
const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params;
|
||||
|
||||
if (resolvedMode === 'MIXED') {
|
||||
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
|
||||
return buildMixedWagonPlan(
|
||||
containerBookings,
|
||||
bulkBookings,
|
||||
containerWagonType,
|
||||
bulkWagonType,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedMode === 'BULK') {
|
||||
return buildBulkWagonPlan(bookings, bulkWagonType);
|
||||
}
|
||||
|
||||
return buildContainerWagonPlan(bookings, containerWagonType);
|
||||
}
|
||||
|
||||
export function summarizeFleetWarnings(
|
||||
fleetAvailability: FleetAvailabilityRow[],
|
||||
deferred: DeferredBookingRow[],
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) {
|
||||
warnings.push(
|
||||
`Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Name the bookings the shortage actually hits, with their own per-type counts,
|
||||
// so staff know WHAT is held out — not just that the pool is short overall.
|
||||
for (const row of deferred) {
|
||||
if (!row.shortage) continue;
|
||||
warnings.push(
|
||||
`Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
|
||||
`only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (deferred.length) {
|
||||
warnings.push(
|
||||
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function totalAssignedWeight(bookings: Booking[]): number {
|
||||
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
buildMixedWagonPlan,
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
expandContainerItems,
|
||||
maxEdgeConsistUsage,
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
validateContainerPlacements,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
} as WagonType;
|
||||
|
||||
const cw3: WagonType = {
|
||||
id: 'wt-cw3',
|
||||
code: 'CW3',
|
||||
name: 'Covered Wagon',
|
||||
capacityTons: 60,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
|
||||
const makeContainerBooking = (
|
||||
id: string,
|
||||
lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>,
|
||||
): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: lines.reduce(
|
||||
(sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25),
|
||||
0,
|
||||
),
|
||||
bookingContainers: lines.map((line, index) => ({
|
||||
id: `${id}-line-${index}`,
|
||||
containerTypeId: `ct-${index}`,
|
||||
quantity: line.quantity,
|
||||
wagonsRequired: line.wagonsRequired,
|
||||
vgmPerUnitTons: line.vgmPerUnitTons ?? 25,
|
||||
})),
|
||||
}) as Booking;
|
||||
|
||||
describe('wagon-plan.util', () => {
|
||||
it('uses slot-based planning: 2×20ft = 1 wagon slot', () => {
|
||||
const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
expect(plan).toHaveLength(1);
|
||||
expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container);
|
||||
});
|
||||
|
||||
it('uses slot-based planning: 1×40ft = 1 wagon slot', () => {
|
||||
const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
expect(plan).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sums wagons across multiple container lines', () => {
|
||||
const booking = makeContainerBooking('b3', [
|
||||
{ quantity: 2, wagonsRequired: 1 },
|
||||
{ quantity: 1, wagonsRequired: 1 },
|
||||
]);
|
||||
expect(sumWagonsRequired(booking)).toBe(2);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
expect(plan).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => {
|
||||
// 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired
|
||||
// must carry all of them so gross weight charges 12 tares downstream.
|
||||
const booking = {
|
||||
id: 'bulk-700',
|
||||
reference: 'bulk-700',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 700,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
const plan = buildBulkWagonPlan([booking], cw3);
|
||||
expect(plan).toHaveLength(12);
|
||||
expect(sumWagonsRequired(booking, plan)).toBe(12);
|
||||
// Without a plan the pre-plan fallback still applies.
|
||||
expect(sumWagonsRequired(booking)).toBe(1);
|
||||
});
|
||||
|
||||
it('counts container wagons from the plan TEU packing', () => {
|
||||
const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
expect(sumWagonsRequired(booking, plan)).toBe(3);
|
||||
});
|
||||
|
||||
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
||||
// 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons
|
||||
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||
expect(sumWagonsRequired(booking)).toBe(3);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
expect(plan).toHaveLength(3);
|
||||
// Verify sequence numbers are 1, 2, 3
|
||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('expands container items per quantity', () => {
|
||||
const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]);
|
||||
const items = expandContainerItems(booking, 'alloc-1');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1');
|
||||
});
|
||||
|
||||
it('rounds tons to three decimal places', () => {
|
||||
expect(roundTons(1.23456)).toBe(1.235);
|
||||
expect(roundTons('bad')).toBe(0);
|
||||
});
|
||||
|
||||
it('builds mixed plan with container block before bulk', () => {
|
||||
const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]);
|
||||
const bulkBooking = {
|
||||
id: 'b1',
|
||||
reference: 'BKG-BULK',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3);
|
||||
expect(plan).toHaveLength(4);
|
||||
expect(plan[0]?.slotLoadType).toBe('CONTAINER');
|
||||
expect(plan[2]?.slotLoadType).toBe('BULK');
|
||||
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('expands booking container units for UI rows', () => {
|
||||
const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]);
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[1]?.unitIndex).toBe(1);
|
||||
expect(units[1]?.bookingContainerId).toBe('c2-line-0');
|
||||
});
|
||||
|
||||
it('validates required placements per container unit', () => {
|
||||
const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
const violations = validateContainerPlacements([booking], plan, []);
|
||||
expect(violations.some((v) => v.includes('required'))).toBe(true);
|
||||
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
const placements = units.map((unit, index) => ({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo: plan[index]?.sequenceNo ?? 1,
|
||||
containerNumber: `CNTR-${index + 1}`,
|
||||
}));
|
||||
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects 20ft container over max individual weight', () => {
|
||||
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
const placements = units.map((unit, index) => ({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo: 1,
|
||||
containerNumber: `CNTR-${index + 1}`,
|
||||
}));
|
||||
|
||||
const violations = validate20ftContainerRules(units, placements, {
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
});
|
||||
|
||||
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
||||
const booking = makeContainerBooking('c21', [
|
||||
{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 },
|
||||
]);
|
||||
booking.bookingContainers![0]!.vgmPerUnitTons = 25;
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
units[1]!.grossWeightTons = 10;
|
||||
const placements = units.map((unit) => ({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo: 1,
|
||||
containerNumber: `CNTR-${unit.unitIndex}`,
|
||||
}));
|
||||
|
||||
const violations = validate20ftContainerRules(units, placements, {
|
||||
max20ftContainerWeightTons: 30,
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
});
|
||||
|
||||
expect(violations.some((v) => v.includes('weight difference'))).toBe(true);
|
||||
});
|
||||
|
||||
it('builds bulk-only plan as degenerate mixed case', () => {
|
||||
const bulkBooking = {
|
||||
id: 'b2',
|
||||
reference: 'BKG-BULK-2',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 60,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3);
|
||||
expect(plan).toHaveLength(1);
|
||||
expect(plan[0]?.slotLoadType).toBe('BULK');
|
||||
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
|
||||
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
|
||||
quantity,
|
||||
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
||||
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
});
|
||||
|
||||
it('20×20ft = 10 wagons (not 20)', () => {
|
||||
expect(containerWagonsForLines([line(20, 0.5)])).toBe(10);
|
||||
});
|
||||
|
||||
it('38×20ft = 19 wagons', () => {
|
||||
expect(containerWagonsForLines([line(38, 0.5)])).toBe(19);
|
||||
});
|
||||
|
||||
it('2×20ft = 1 wagon', () => {
|
||||
expect(containerWagonsForLines([line(2, 0.5)])).toBe(1);
|
||||
});
|
||||
|
||||
it('odd 3×20ft = 2 wagons (single line ceils)', () => {
|
||||
expect(containerWagonsForLines([line(3, 0.5)])).toBe(2);
|
||||
});
|
||||
|
||||
it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => {
|
||||
// per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3.
|
||||
expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3);
|
||||
});
|
||||
|
||||
it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => {
|
||||
// per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2.
|
||||
expect(
|
||||
containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('5×20ft + 2×40ft = 5 wagons', () => {
|
||||
expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5);
|
||||
});
|
||||
|
||||
it('21×40ft = 21 wagons', () => {
|
||||
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
|
||||
});
|
||||
|
||||
it('falls back to line wagonsRequired when containerType/sizeFt missing', () => {
|
||||
// No containerType relation loaded → use the stored (0.5-aware) fraction.
|
||||
expect(
|
||||
containerWagonsForLines([
|
||||
{ quantity: 20, wagonsRequired: 10 } as never,
|
||||
]),
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
it('empty line set = 0 wagons', () => {
|
||||
expect(containerWagonsForLines([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
|
||||
const slot = (
|
||||
tare: number,
|
||||
cargo: number,
|
||||
length: number,
|
||||
board?: string | null,
|
||||
alight?: string | null,
|
||||
) =>
|
||||
({
|
||||
tareWeightTons: tare,
|
||||
assignedWeightTons: cargo,
|
||||
lengthMeters: length,
|
||||
boardYardId: board ?? null,
|
||||
alightYardId: alight ?? null,
|
||||
}) as never;
|
||||
|
||||
const stops = ['a', 'b', 'c'];
|
||||
|
||||
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
|
||||
const plan = [
|
||||
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
|
||||
slot(24, 65, 14, 'b', null), // export, rides b→c only
|
||||
];
|
||||
// Each edge carries one slot: 89T gross / 14m — never 178T.
|
||||
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
|
||||
grossWeightTons: 89,
|
||||
lengthMeters: 14,
|
||||
loadedWagonCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
|
||||
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
|
||||
// shared edge a→b carries all 40 slots = 3547.6T gross.
|
||||
const plan = [
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
|
||||
];
|
||||
const usage = maxEdgeConsistUsage(plan, stops);
|
||||
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
|
||||
expect(usage.lengthMeters).toBe(560);
|
||||
});
|
||||
|
||||
it('degrades to whole-train totals on a two-stop route', () => {
|
||||
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
|
||||
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
|
||||
grossWeightTons: 178,
|
||||
lengthMeters: 28,
|
||||
loadedWagonCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,884 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
=======
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
export const MAX_TEU_SLOTS_PER_WAGON = 2;
|
||||
|
||||
export type TrainLimitConfig = {
|
||||
maxWeightTons?: number;
|
||||
maxLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
max20ftContainerWeightTons?: number;
|
||||
max20ftPairWeightDiffTons?: number;
|
||||
};
|
||||
|
||||
export type ContainerPlacementRules = {
|
||||
max20ftContainerWeightTons?: number;
|
||||
max20ftPairWeightDiffTons?: number;
|
||||
};
|
||||
|
||||
export type WagonAllocationRecord = {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
allocatedWeightTons: number;
|
||||
loadType: AllocationLoadType;
|
||||
};
|
||||
|
||||
export type SlotLoadType = 'CONTAINER' | 'BULK';
|
||||
|
||||
export type WagonPlanSlot = {
|
||||
sequenceNo: number;
|
||||
wagonTypeId: string;
|
||||
wagonTypeCode: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
|
||||
tareWeightTons: number;
|
||||
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
/**
|
||||
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
|
||||
* at boardYardId and alights at alightYardId. Null = the schedule's own
|
||||
* endpoint (whole-route slot, legacy behavior).
|
||||
*/
|
||||
boardYardId?: string | null;
|
||||
alightYardId?: string | null;
|
||||
};
|
||||
|
||||
export type ContainerUnitRow = {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
containerTypeId: string;
|
||||
containerTypeCode: string;
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
};
|
||||
|
||||
export type ContainerPlacementInput = {
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
sequenceNo: number;
|
||||
containerId?: string;
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
};
|
||||
|
||||
export function roundTons(value: number | string | null | undefined): number {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
if (!Number.isFinite(numericValue)) return 0;
|
||||
return Number(numericValue.toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
|
||||
* a missing tare must read as 0 rather than silently inventing dead weight.
|
||||
*/
|
||||
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
|
||||
return roundTons(wagonType.tareWeightTons ?? 0);
|
||||
}
|
||||
|
||||
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
}
|
||||
|
||||
type ContainerLine = {
|
||||
quantity?: number | null;
|
||||
wagonsRequired?: number | null;
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
|
||||
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
|
||||
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
|
||||
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
|
||||
* wagons, not 4).
|
||||
*/
|
||||
function lineWagonsRaw(line: ContainerLine): number {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
if (qty <= 0) return 0;
|
||||
const sizeFt = Number(line.containerType?.sizeFt);
|
||||
if (Number.isFinite(sizeFt) && sizeFt > 0) {
|
||||
return qty * wagonsPerUnitForSize(sizeFt);
|
||||
}
|
||||
// No size on the type: fall back to the line's stored fraction, else treat
|
||||
// the whole line as one wagon.
|
||||
const stored = Number(line.wagonsRequired);
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
|
||||
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
|
||||
*/
|
||||
export function containerWagonsForLines(lines: ContainerLine[]): number {
|
||||
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
|
||||
return raw > 0 ? Math.ceil(raw) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
|
||||
*/
|
||||
export function buildContainerWagonPlan(
|
||||
bookings: Booking[],
|
||||
wagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
|
||||
// can share a wagon with another 20ft of the SAME booking, never across
|
||||
// bookings), then sum. Ceiling per line instead would over-count a booking
|
||||
// that splits its 20ft units across several lines.
|
||||
const totalSlots = bookings.reduce((sum, booking) => {
|
||||
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
return sum + Math.max(bookingSlots, 1);
|
||||
}, 0);
|
||||
|
||||
const slots = Math.max(1, totalSlots);
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: Number(wagonType.capacityTons),
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
|
||||
return allocateContainersToSlots(bookings, basePlan).map((slot) => ({
|
||||
...slot,
|
||||
slotLoadType: 'CONTAINER' as SlotLoadType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build weight-based wagon plan for BULK bookings.
|
||||
*/
|
||||
export function buildBulkWagonPlan(
|
||||
bookings: Booking[],
|
||||
wagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
const capacity = Number(wagonType.capacityTons);
|
||||
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
|
||||
// their tonnage must NOT pool with PER_TON cargo (an item can't split
|
||||
// across wagons the way loose tonnage can).
|
||||
const itemSlotsByBooking = bookings.map((b) =>
|
||||
// The plan fixed THIS wagon type, so its configured items-fit binds — not
|
||||
// the best fit across the cargo's allowed types.
|
||||
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
|
||||
);
|
||||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
|
||||
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
|
||||
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
|
||||
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
|
||||
const cappedTonSlotsByBooking = bookings.map((b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
|
||||
? 0
|
||||
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
|
||||
);
|
||||
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
(sum, b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
|
||||
? sum
|
||||
: sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
|
||||
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
|
||||
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: capacity,
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
|
||||
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({
|
||||
...slot,
|
||||
slotLoadType: 'BULK' as SlotLoadType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers.
|
||||
*/
|
||||
export function buildMixedWagonPlan(
|
||||
containerBookings: Booking[],
|
||||
bulkBookings: Booking[],
|
||||
containerWagonType: WagonType,
|
||||
bulkWagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
const containerPlan = containerBookings.length
|
||||
? buildContainerWagonPlan(containerBookings, containerWagonType)
|
||||
: [];
|
||||
const bulkPlan = bulkBookings.length
|
||||
? buildBulkWagonPlan(bulkBookings, bulkWagonType)
|
||||
: [];
|
||||
|
||||
const tagged: WagonPlanSlot[] = [
|
||||
...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })),
|
||||
...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })),
|
||||
];
|
||||
|
||||
if (!tagged.length) {
|
||||
return [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
wagonTypeId: containerWagonType.id,
|
||||
wagonTypeCode: containerWagonType.code,
|
||||
capacityTons: Number(containerWagonType.capacityTons),
|
||||
lengthMeters: Number(containerWagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(containerWagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
slotLoadType: 'CONTAINER',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return tagged.map((slot, index) => ({
|
||||
...slot,
|
||||
sequenceNo: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] {
|
||||
const rows: ContainerUnitRow[] = [];
|
||||
|
||||
for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) {
|
||||
for (const line of booking.bookingContainers ?? []) {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||||
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||||
const perWagon = containersPerWagonForSize(sizeFt);
|
||||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||
// The REAL per-container numbers/weights entered at booking time. Unit i of
|
||||
// the line maps to units[i] (sortOrder order); the line-level number is only
|
||||
// a legacy fallback — never invent numbers here.
|
||||
const units = [...(line.units ?? [])].sort(
|
||||
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
|
||||
);
|
||||
for (let i = 0; i < qty; i += 1) {
|
||||
const unit = units[i];
|
||||
rows.push({
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
bookingContainerId: line.id,
|
||||
unitIndex: i,
|
||||
containerTypeId: line.containerTypeId ?? '',
|
||||
containerTypeCode: code,
|
||||
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||||
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
||||
sizeFt,
|
||||
containersPerWagon: perWagon,
|
||||
teuSlots,
|
||||
containerNumber:
|
||||
unit?.containerNumber?.trim() || line.containerNumber || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] {
|
||||
return wagonPlan
|
||||
.filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some(
|
||||
(a) => a.loadType === AllocationLoadType.Container,
|
||||
))
|
||||
.map((slot) => slot.sequenceNo);
|
||||
}
|
||||
|
||||
function allocateBookingsToSlots(
|
||||
bookings: Booking[],
|
||||
basePlan: WagonPlanSlot[],
|
||||
loadType: AllocationLoadType,
|
||||
): WagonPlanSlot[] {
|
||||
const remaining = bookings.map((booking) => ({
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
|
||||
// bookings that column is an item COUNT, not tons.
|
||||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||||
cargoType: booking.cargoType,
|
||||
}));
|
||||
|
||||
let bookingIndex = 0;
|
||||
|
||||
return basePlan.map((slot) => {
|
||||
let wagonRemaining = roundTons(slot.capacityTons);
|
||||
const allocations: WagonAllocationRecord[] = [];
|
||||
let assignedWeightTons = 0;
|
||||
|
||||
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||||
const booking = remaining[bookingIndex];
|
||||
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
|
||||
// as the wagon count — the plan reserved a wagon per capped chunk, so
|
||||
// pouring rated capacity into it would leave the last wagon empty.
|
||||
const takeCap = Math.min(
|
||||
wagonRemaining,
|
||||
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
|
||||
);
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(takeCap, booking.remainingWeightTons),
|
||||
);
|
||||
|
||||
if (allocatedWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
allocations.push({
|
||||
bookingId: booking.bookingId,
|
||||
bookingReference: booking.bookingReference,
|
||||
allocatedWeightTons,
|
||||
loadType,
|
||||
});
|
||||
|
||||
booking.remainingWeightTons = roundTons(
|
||||
booking.remainingWeightTons - allocatedWeightTons,
|
||||
);
|
||||
wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons);
|
||||
assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons);
|
||||
|
||||
if (booking.remainingWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
} else if (allocatedWeightTons >= takeCap) {
|
||||
// The cap stopped this wagon short of its rating and the booking has
|
||||
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
|
||||
// already reserved a wagon for the rest, so backfilling another booking
|
||||
// here would double-book the consist. Close the wagon.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...slot, assignedWeightTons, allocations };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most
|
||||
* 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU
|
||||
* each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container
|
||||
* maps to a real wagon allocation, and this mirrors the frontend auto-fill packing
|
||||
* exactly so a placement's sequenceNo always lands on a slot that holds an allocation
|
||||
* for its booking.
|
||||
*
|
||||
* Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses
|
||||
* several light containers into the first wagons by tonnage and leaves later container
|
||||
* units without an allocation slot, which silently drops their container items on assign.
|
||||
*/
|
||||
function allocateContainersToSlots(
|
||||
bookings: Booking[],
|
||||
basePlan: WagonPlanSlot[],
|
||||
): WagonPlanSlot[] {
|
||||
const slots = basePlan.map((slot) => ({
|
||||
...slot,
|
||||
assignedWeightTons: 0,
|
||||
allocations: [] as WagonAllocationRecord[],
|
||||
}));
|
||||
if (!slots.length) return slots;
|
||||
|
||||
const units = expandBookingContainerUnits(bookings);
|
||||
let currentSlotIndex = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||
|
||||
// Move to the next wagon once this one can't fit the container's TEU. This keeps a
|
||||
// 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft.
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!;
|
||||
|
||||
let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId);
|
||||
if (!allocation) {
|
||||
allocation = {
|
||||
bookingId: unit.bookingId,
|
||||
bookingReference: unit.bookingReference,
|
||||
allocatedWeightTons: 0,
|
||||
loadType: AllocationLoadType.Container,
|
||||
};
|
||||
slot.allocations.push(allocation);
|
||||
}
|
||||
allocation.allocatedWeightTons = roundTons(
|
||||
allocation.allocatedWeightTons + unit.grossWeightTons,
|
||||
);
|
||||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons);
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function expandContainerItems(
|
||||
booking: Booking,
|
||||
allocationId: string,
|
||||
): Array<{
|
||||
wagonBookingAllocationId: string;
|
||||
bookingContainerId: string;
|
||||
containerTypeId: string;
|
||||
grossWeightTons: number;
|
||||
positionOnWagon: number | null;
|
||||
}> {
|
||||
const items: Array<{
|
||||
wagonBookingAllocationId: string;
|
||||
bookingContainerId: string;
|
||||
containerTypeId: string;
|
||||
grossWeightTons: number;
|
||||
positionOnWagon: number | null;
|
||||
}> = [];
|
||||
|
||||
for (const line of booking.bookingContainers ?? []) {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
for (let i = 0; i < qty; i += 1) {
|
||||
items.push({
|
||||
wagonBookingAllocationId: allocationId,
|
||||
bookingContainerId: line.id,
|
||||
containerTypeId: line.containerTypeId ?? '',
|
||||
grossWeightTons: Number(line.vgmPerUnitTons),
|
||||
positionOnWagon: qty > 1 ? i + 1 : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a booking actually occupies. Prefer counting the built wagon plan's
|
||||
* slots that carry one of the booking's allocations — for BULK that is its
|
||||
* tonnage spread over real wagons (a 700T booking on 70T wagons rides 10
|
||||
* wagons, and downstream gross-weight math charges 10 tares, not 1). Without
|
||||
* a plan there is no capacity to divide by, so fall back to the pre-plan
|
||||
* estimates: 1 for bulk, the lines' stored counts for containers.
|
||||
*/
|
||||
export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number {
|
||||
const occupiedSlots = (wagonPlan ?? []).filter((slot) =>
|
||||
slot.allocations.some((allocation) => allocation.bookingId === booking.id),
|
||||
).length;
|
||||
if (occupiedSlots > 0) {
|
||||
return occupiedSlots;
|
||||
}
|
||||
if (booking.freightType === 'BULK') {
|
||||
return 1;
|
||||
}
|
||||
return (booking.bookingContainers ?? []).reduce(
|
||||
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
|
||||
if (slot.assignedWeightTons > slot.capacityTons) {
|
||||
violations.push(
|
||||
`Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a consist against its train's three limits. Weight is GROSS — every slot
|
||||
* contributes its own tare plus the cargo assigned to it — because the locomotive
|
||||
* pull limit governs what it drags, not what was sold. Length and tare are summed
|
||||
* per slot, so a mixed consist is measured as it actually stands rather than
|
||||
* through one representative wagon type.
|
||||
*
|
||||
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
|
||||
* is absent; slot dimensions always win over it.
|
||||
*/
|
||||
export function validateTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonSlots =
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const violations = consistViolations(
|
||||
wagonPlan.map((slot) => ({
|
||||
lengthMeters: Number(slot.lengthMeters),
|
||||
tareWeightTons: Number(slot.tareWeightTons ?? 0),
|
||||
cargoTons: Number(slot.assignedWeightTons),
|
||||
})),
|
||||
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
|
||||
);
|
||||
|
||||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixed consist: the wagon-count fallback uses the shortest type present, since
|
||||
* that is the most wagons that could ever fit. Weight and length still come from
|
||||
* the slots themselves.
|
||||
*/
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const minWagonLength = Math.min(
|
||||
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
|
||||
14,
|
||||
);
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
{ lengthMeters: minWagonLength },
|
||||
{ ...limits, maxWagonsPerTrain },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||||
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||||
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||||
* wagon on Gelan→Adama never counts against a train that is full only on
|
||||
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||||
*/
|
||||
export function validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits: TrainLimitConfig | undefined,
|
||||
stops: string[],
|
||||
/** Display names parallel to `stops` — violations then name the leg they hit. */
|
||||
stopLabels?: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||
violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`);
|
||||
}
|
||||
}
|
||||
return [...violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot fields per-edge usage math actually reads — lets callers feed
|
||||
* persisted TrainSetWagon rows (or any structural subset), not only plan slots.
|
||||
*/
|
||||
export type EdgeUsageSlot = Pick<
|
||||
WagonPlanSlot,
|
||||
'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons'
|
||||
> & {
|
||||
boardYardId?: string | null;
|
||||
alightYardId?: string | null;
|
||||
allocations?: unknown[];
|
||||
};
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
): Array<{ from: number; to: number }> {
|
||||
const lastIdx = stops.length - 1;
|
||||
return wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
|
||||
* summed over only the slots riding each edge, maxed across edges. This is the
|
||||
* figure a locomotive pull/length limit must be compared against — a train is
|
||||
* never heavier than its heaviest single leg, so summing disjoint legs
|
||||
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
|
||||
* Two stops or fewer degrade to the whole-train totals.
|
||||
*/
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||||
return perEdgeConsistUsage(wagonPlan, stops).reduce(
|
||||
(max, e) => ({
|
||||
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount),
|
||||
}),
|
||||
{ grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */
|
||||
export type EdgeConsistUsage = {
|
||||
edge: number;
|
||||
grossWeightTons: number;
|
||||
lengthMeters: number;
|
||||
loadedWagonCount: number;
|
||||
wagonCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own
|
||||
* consist totals, so callers can name WHICH leg breaks a limit instead of
|
||||
* only reporting the heaviest figure. Two stops or fewer collapse to a
|
||||
* single whole-route edge.
|
||||
*/
|
||||
export function perEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
): EdgeConsistUsage[] {
|
||||
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
||||
edge,
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
|
||||
wagonCount: slots.length,
|
||||
});
|
||||
if (stops.length <= 2) return [totals(0, wagonPlan)];
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
return Array.from({ length: stops.length - 1 }, (_, edge) =>
|
||||
totals(
|
||||
edge,
|
||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
rules?: ContainerPlacementRules,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const maxEach = rules?.max20ftContainerWeightTons;
|
||||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||
if (maxEach == null && maxDiff == null) return violations;
|
||||
|
||||
const placementByUnit = new Map(
|
||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||
);
|
||||
|
||||
const weightsBySlot = new Map<number, number[]>();
|
||||
|
||||
for (const unit of units) {
|
||||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||||
if (sizeFt >= 40) continue;
|
||||
|
||||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||
violations.push(
|
||||
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
|
||||
);
|
||||
}
|
||||
|
||||
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||
if (!placement?.sequenceNo) continue;
|
||||
|
||||
const list = weightsBySlot.get(placement.sequenceNo) ?? [];
|
||||
list.push(unit.grossWeightTons);
|
||||
weightsBySlot.set(placement.sequenceNo, list);
|
||||
}
|
||||
|
||||
if (maxDiff != null) {
|
||||
for (const [sequenceNo, weights] of weightsBySlot.entries()) {
|
||||
if (weights.length < 2) continue;
|
||||
const diff = Math.abs(weights[0]! - weights[1]!);
|
||||
if (diff > maxDiff) {
|
||||
violations.push(
|
||||
`Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function validateContainerPlacements(
|
||||
containerBookings: Booking[],
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
placements: ContainerPlacementInput[],
|
||||
rules?: ContainerPlacementRules,
|
||||
/**
|
||||
* Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg.
|
||||
* With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity
|
||||
* 20ft and an export 20ft coexist on one wagon when their edges allow it.
|
||||
* Omitted → one edge, byte-identical to the whole-route check.
|
||||
*/
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
edgeCount?: number,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const units = expandBookingContainerUnits(containerBookings);
|
||||
if (!units.length) return violations;
|
||||
|
||||
const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan));
|
||||
const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`));
|
||||
const placementKeys = new Set<string>();
|
||||
const containerNumbers = new Set<string>();
|
||||
|
||||
if (!placements.length) {
|
||||
violations.push('Container placements are required for container bookings');
|
||||
return violations;
|
||||
}
|
||||
|
||||
for (const placement of placements) {
|
||||
const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`;
|
||||
if (!unitKeys.has(unitKey)) {
|
||||
violations.push(
|
||||
`Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (placementKeys.has(unitKey)) {
|
||||
violations.push(`Duplicate placement for container unit ${unitKey}`);
|
||||
}
|
||||
placementKeys.add(unitKey);
|
||||
|
||||
if (!containerSlots.has(placement.sequenceNo)) {
|
||||
violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`);
|
||||
}
|
||||
|
||||
const hasInventory = Boolean(placement.containerId);
|
||||
const hasManual = Boolean(placement.containerNumber?.trim());
|
||||
if (!hasInventory && !hasManual) {
|
||||
violations.push(
|
||||
`Container unit ${unitKey} requires an existing container or a new container number`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasManual) {
|
||||
const normalized = placement.containerNumber!.trim().toUpperCase();
|
||||
if (containerNumbers.has(normalized)) {
|
||||
violations.push(`Duplicate container number ${normalized}`);
|
||||
}
|
||||
containerNumbers.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for (const unit of units) {
|
||||
const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`;
|
||||
if (!placementKeys.has(unitKey)) {
|
||||
violations.push(`Missing placement for ${unit.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
|
||||
// a single edge and this is exactly the old whole-route accounting.
|
||||
const edges = Math.max(1, edgeCount ?? 1);
|
||||
const legOf = (bookingId: string): { from: number; to: number } => {
|
||||
const leg = legs?.get(bookingId);
|
||||
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
|
||||
return { from: 0, to: edges };
|
||||
}
|
||||
return leg;
|
||||
};
|
||||
const slotTeuUsed = new Map<number, number[]>();
|
||||
const slotWeightUsed = new Map<number, number[]>();
|
||||
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
|
||||
|
||||
for (const placement of placements) {
|
||||
const unit = units.find(
|
||||
(u) =>
|
||||
u.bookingContainerId === placement.bookingContainerId &&
|
||||
u.unitIndex === placement.unitIndex,
|
||||
);
|
||||
if (!unit) continue;
|
||||
|
||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||
const leg = legOf(unit.bookingId);
|
||||
const teuRow =
|
||||
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
|
||||
let teuFits = true;
|
||||
for (let e = leg.from; e < leg.to; e += 1) {
|
||||
if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||||
teuFits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!teuFits) {
|
||||
violations.push(
|
||||
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
||||
);
|
||||
} else {
|
||||
for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu;
|
||||
slotTeuUsed.set(placement.sequenceNo, teuRow);
|
||||
}
|
||||
|
||||
const slot = slotBySeq.get(placement.sequenceNo);
|
||||
if (slot) {
|
||||
const weightRow =
|
||||
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
|
||||
let heaviestEdge = 0;
|
||||
for (let e = leg.from; e < leg.to; e += 1) {
|
||||
weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons);
|
||||
heaviestEdge = Math.max(heaviestEdge, weightRow[e]);
|
||||
}
|
||||
slotWeightUsed.set(placement.sequenceNo, weightRow);
|
||||
if (heaviestEdge > slot.capacityTons) {
|
||||
violations.push(
|
||||
`Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
violations.push(...validate20ftContainerRules(units, placements, rules));
|
||||
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
|
||||
import {
|
||||
requiredWagonReadiness,
|
||||
wagonReadinessMatchesSchedule,
|
||||
} from './wagon-readiness.util';
|
||||
|
||||
describe('wagonReadinessMatchesSchedule', () => {
|
||||
it('requires IMPORT_READY for IMPORT schedules', () => {
|
||||
expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady);
|
||||
expect(
|
||||
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires EXPORT_READY for EXPORT schedules', () => {
|
||||
expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady);
|
||||
expect(
|
||||
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows any readiness for DOMESTIC schedules', () => {
|
||||
expect(requiredWagonReadiness('DOMESTIC')).toBeNull();
|
||||
expect(
|
||||
wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
|
||||
export function requiredWagonReadiness(
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
): WagonReadiness | null {
|
||||
if (direction === 'IMPORT') return WagonReadiness.ImportReady;
|
||||
if (direction === 'EXPORT') return WagonReadiness.ExportReady;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
|
||||
export function wagonReadinessMatchesSchedule(
|
||||
wagonReadiness: WagonReadiness | string,
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
): boolean {
|
||||
const required = requiredWagonReadiness(direction);
|
||||
if (!required) return true;
|
||||
return wagonReadiness === required;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
|
||||
*/
|
||||
export function flipReadiness(
|
||||
readiness: WagonReadiness | string,
|
||||
): WagonReadiness {
|
||||
return readiness === WagonReadiness.ImportReady
|
||||
? WagonReadiness.ExportReady
|
||||
: WagonReadiness.ImportReady;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
|
||||
COFFEE: 'KW2',
|
||||
GRAIN: 'KW2',
|
||||
WHEAT: 'KW2',
|
||||
SORGHUM: 'KW2',
|
||||
CORN: 'KW2',
|
||||
FERTILIZER: 'PW2',
|
||||
SUGAR: 'PW2',
|
||||
COAL: 'KW3',
|
||||
STEEL: 'CW3',
|
||||
ORE: 'CW3',
|
||||
};
|
||||
|
||||
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
|
||||
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
|
||||
|
||||
/**
|
||||
* Resolve wagon type code from cargo type code for bulk freight.
|
||||
*/
|
||||
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
|
||||
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
|
||||
const normalized = cargoTypeCode.trim().toUpperCase();
|
||||
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best matching wagon type entity for bulk cargo.
|
||||
*/
|
||||
export function pickBulkWagonType(
|
||||
wagonTypes: WagonType[],
|
||||
cargoTypeCode?: string | null,
|
||||
): WagonType | undefined {
|
||||
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
|
||||
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
|
||||
if (direct) return direct;
|
||||
|
||||
return wagonTypes.find(
|
||||
(wt) =>
|
||||
wt.isActive &&
|
||||
!wt.supportsContainer &&
|
||||
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultContainerWagonTypeCode(): string {
|
||||
return DEFAULT_CONTAINER_WAGON_TYPE;
|
||||
}
|
||||
Reference in New Issue
Block a user