booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -0,0 +1,244 @@
import { describe, it, expect } from 'vitest';
import { autoFillPlacements, unitKey, validateLocalPlacements } from './containerPlacement.util';
import type { ContainerUnitRow } from '@/types/trainScheduling';
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
const units: ContainerUnitRow[] = [];
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
for (let i = 0; i < quantity; i++) {
units.push({
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: i,
containerTypeId: 'ct-1',
containerTypeCode: containerType,
label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25,
sizeFt,
wagonsPerUnit,
containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1,
});
}
return units;
}
describe('containerPlacement.util', () => {
describe('unitKey', () => {
it('creates unique keys for units', () => {
expect(unitKey('bc-1', 0)).toBe('bc-1:0');
expect(unitKey('bc-1', 1)).toBe('bc-1:1');
expect(unitKey('bc-2', 0)).toBe('bc-2:0');
});
});
describe('autoFillPlacements', () => {
it('returns empty array when no units or slots', () => {
expect(autoFillPlacements([], [1, 2, 3])).toEqual([]);
expect(autoFillPlacements(makeUnits('20GP', 20, 1), [])).toEqual([]);
});
it('places 2×20ft containers in 1 wagon slot', () => {
const units = makeUnits('20GP', 20, 2);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(2);
// Both 20ft containers should be in slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('places 6×20ft containers in 3 wagon slots (2 per wagon)', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// Units 0,1 -> Slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// Units 2,3 -> Slot 2
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Units 4,5 -> Slot 3
expect(placements[4]?.sequenceNo).toBe(3);
expect(placements[5]?.sequenceNo).toBe(3);
});
it('places 1×40ft container in 1 wagon slot', () => {
const units = makeUnits('40GP', 40, 1);
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(1);
expect(placements[0]?.sequenceNo).toBe(1);
});
it('places 3×40ft containers in 3 wagon slots (1 per wagon)', () => {
const units = makeUnits('40GP', 40, 3);
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// Each 40ft container gets its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
expect(placements[2]?.sequenceNo).toBe(3);
});
it('handles mixed 20ft and 40ft containers correctly', () => {
const units20 = makeUnits('20GP', 20, 2);
const units40 = makeUnits('40GP', 40, 1);
const units = [...units20, ...units40];
const slots = [1, 2, 3, 4, 5];
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(3);
// First two 20ft containers share slot 1
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
// 40ft container gets slot 2
expect(placements[2]?.sequenceNo).toBe(2);
});
it('falls back to last slot when running out of slots', () => {
const units = makeUnits('20GP', 20, 6);
const slots = [1, 2]; // Only 2 slots available
const placements = autoFillPlacements(units, slots);
expect(placements).toHaveLength(6);
// First 4 units fit in slots 1 and 2
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
expect(placements[2]?.sequenceNo).toBe(2);
expect(placements[3]?.sequenceNo).toBe(2);
// Remaining units fall back to last available slot (slot 2)
expect(placements[4]?.sequenceNo).toBe(2);
expect(placements[5]?.sequenceNo).toBe(2);
});
it('defaults to 2 containers per wagon when sizeFt is not provided', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 1',
grossWeightTons: 25,
// sizeFt not provided, should default to 2 per wagon
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '20GP',
label: 'Container 2',
grossWeightTons: 25,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(1);
});
it('uses 1 container per wagon for 40ft when sizeFt is 40', () => {
const units: ContainerUnitRow[] = [
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 0,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 1',
grossWeightTons: 25,
sizeFt: 40,
},
{
bookingId: 'booking-1',
bookingReference: 'BKG-001',
bookingContainerId: 'bc-1',
unitIndex: 1,
containerTypeId: 'ct-1',
containerTypeCode: '40GP',
label: 'Container 2',
grossWeightTons: 25,
sizeFt: 40,
},
];
const slots = [1, 2, 3];
const placements = autoFillPlacements(units, slots);
// Each 40ft container should get its own slot
expect(placements[0]?.sequenceNo).toBe(1);
expect(placements[1]?.sequenceNo).toBe(2);
});
});
describe('validateLocalPlacements', () => {
it('returns empty array for valid placements', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
];
expect(validateLocalPlacements(units, placements)).toEqual([]);
});
it('returns error for missing slot', () => {
const units = makeUnits('20GP', 20, 1);
const placements: ReturnType<typeof autoFillPlacements> = [];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Slot missing'))).toBe(true);
});
it('returns error for missing container number', () => {
const units = makeUnits('20GP', 20, 1);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
// No containerNumber or containerId
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Enter a container number'))).toBe(true);
});
it('returns error for duplicate container numbers', () => {
const units = makeUnits('20GP', 20, 2);
const placements = [
{
bookingContainerId: 'bc-1',
unitIndex: 0,
sequenceNo: 1,
containerNumber: 'CNTR123',
},
{
bookingContainerId: 'bc-1',
unitIndex: 1,
sequenceNo: 1,
containerNumber: 'CNTR123', // Duplicate!
},
];
const issues = validateLocalPlacements(units, placements);
expect(issues.some((i) => i.includes('Duplicate container number'))).toBe(true);
});
});
});