mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
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;
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
};
|
||||
|
||||
export type ContainerUnitRow = {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
containerTypeId: string;
|
||||
containerTypeCode: string;
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
function lineWagonsRequired(line: {
|
||||
quantity?: number | null;
|
||||
wagonsRequired?: number | null;
|
||||
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
|
||||
}): number {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
if (qty <= 0) return 0;
|
||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||
if (Number.isFinite(wpu) && wpu > 0) {
|
||||
return Math.ceil(qty * wpu);
|
||||
}
|
||||
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required.
|
||||
*/
|
||||
export function buildContainerWagonPlan(
|
||||
bookings: Booking[],
|
||||
wagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
const totalSlots = bookings.reduce((sum, booking) => {
|
||||
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
||||
(lineSum, line) => lineSum + lineWagonsRequired(line),
|
||||
0,
|
||||
);
|
||||
return sum + Math.max(lineSlots, 1);
|
||||
}, 0);
|
||||
|
||||
const slots = Math.max(1, Math.ceil(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),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
|
||||
return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({
|
||||
...slot,
|
||||
slotLoadType: 'CONTAINER' as SlotLoadType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build weight-based wagon plan for BULK bookings.
|
||||
*/
|
||||
export function buildBulkWagonPlan(
|
||||
bookings: Booking[],
|
||||
wagonType: WagonType,
|
||||
): WagonPlanSlot[] {
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0),
|
||||
);
|
||||
const capacity = Number(wagonType.capacityTons);
|
||||
const slots = Math.max(1, Math.ceil(totalWeight / capacity));
|
||||
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: capacity,
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
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),
|
||||
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 wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
|
||||
const perWagon = containersPerWagonFromType(wagonsPerUnit);
|
||||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||
for (let i = 0; i < qty; i += 1) {
|
||||
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(line.vgmPerUnitTons),
|
||||
sizeFt,
|
||||
wagonsPerUnit,
|
||||
containersPerWagon: perWagon,
|
||||
teuSlots,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
|
||||
}));
|
||||
|
||||
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];
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(wagonRemaining, 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;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...slot, assignedWeightTons, allocations };
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function sumWagonsRequired(booking: Booking): number {
|
||||
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;
|
||||
}
|
||||
|
||||
export function validateTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonType: WagonType,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
|
||||
if (totalWeightTons > maxWeightTons) {
|
||||
violations.push(
|
||||
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (totalLengthMeters > maxLengthMeters) {
|
||||
violations.push(
|
||||
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
if (wagonPlan.length > maxWagonsPerTrain) {
|
||||
violations.push(
|
||||
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
|
||||
);
|
||||
}
|
||||
|
||||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: WagonType[],
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
{ maxWagonsPerTrain } as WagonType,
|
||||
{ ...limits, maxWagonsPerTrain },
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
): 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0;
|
||||
if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) {
|
||||
violations.push(
|
||||
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
|
||||
);
|
||||
} else {
|
||||
slotTeuUsed.set(placement.sequenceNo, usedTeu + teu);
|
||||
}
|
||||
|
||||
const slot = slotBySeq.get(placement.sequenceNo);
|
||||
if (slot) {
|
||||
const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
|
||||
slotWeightUsed.set(placement.sequenceNo, weight);
|
||||
if (weight > slot.capacityTons) {
|
||||
violations.push(
|
||||
`Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
violations.push(...validate20ftContainerRules(units, placements, rules));
|
||||
|
||||
return violations;
|
||||
}
|
||||
Reference in New Issue
Block a user