mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
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 {
|
||
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));
|
||
}
|