mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -1289,6 +1289,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPIRED bookings on the day's corridor — the batch board's expired lane.
|
||||
* Expiry nulls train_schedule_id, so neither findAllBySchedule nor the
|
||||
* ready-pool query can ever see them.
|
||||
*/
|
||||
findExpiredByCorridorDay(
|
||||
corridorYardIds: string[],
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||
corridorYardIds,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(`booking.status = 'EXPIRED'`)
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||
|
||||
@@ -1632,6 +1632,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const b of candidates) {
|
||||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||
}
|
||||
// Expiry frees the schedule pin (expire() nulls train_schedule_id), so
|
||||
// expired bookings match neither query above — merge them back so the
|
||||
// board keeps its expired lane. Display-only: boardState maps them to
|
||||
// EXPIRED, which every capacity meter already excludes.
|
||||
const expiredPool =
|
||||
await this.bookingsRepository.findExpiredByCorridorDay(
|
||||
stops,
|
||||
eatDay(s.scheduledDepartureDate),
|
||||
);
|
||||
for (const b of expiredPool) {
|
||||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||
}
|
||||
} catch (err) {
|
||||
// The board must still render the pinned bookings.
|
||||
this.logger.warn(
|
||||
|
||||
@@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
@@ -7335,7 +7337,14 @@ export class TrainSchedulingService {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
const byWeight =
|
||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||
const wagons = Math.max(1, stored, byLength, byWeight);
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
booking,
|
||||
dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
);
|
||||
const wagons = Math.max(1, stored, byLength, byWeight, byItems);
|
||||
return roundTons(cargo + wagons * dims.tareWeightTons);
|
||||
}
|
||||
|
||||
|
||||
@@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
||||
expect(result.plan).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => {
|
||||
const pw2: WagonType = {
|
||||
id: 'wt-pw2',
|
||||
code: 'PW2',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 17,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
|
||||
// 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3.
|
||||
const machineryBooking = (): Booking =>
|
||||
({
|
||||
id: 'BULK-ITEMS',
|
||||
reference: 'BULK-ITEMS',
|
||||
freightType: 'BULK',
|
||||
cargoTypeId: 'ct-machinery',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
}) as unknown as Booking;
|
||||
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map<string, WagonType[]>(),
|
||||
byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]),
|
||||
};
|
||||
|
||||
it('packs whole items per wagon by the items-fit map, not raw tonnage', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [machineryBooking()],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
// Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each.
|
||||
expect(result.plan).toHaveLength(5);
|
||||
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]);
|
||||
});
|
||||
|
||||
it('weight cap binds before items-fit when items are heavy', () => {
|
||||
// 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon.
|
||||
const heavy = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 14,
|
||||
bulkTotalWeightTons: 140,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [heavy],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]);
|
||||
});
|
||||
|
||||
it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => {
|
||||
const loose = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 100,
|
||||
bulkTotalWeightTons: null,
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [loose],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
@@ -64,6 +69,12 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only —
|
||||
* bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined
|
||||
* for weight-only (PER_TON) bulk and container wagons.
|
||||
*/
|
||||
freeItems?: number;
|
||||
/**
|
||||
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||||
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||||
@@ -110,10 +121,19 @@ const shortageFor = (
|
||||
booking.freightType === 'BULK'
|
||||
? Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||
// respected); PER_TON falls through to tonnage over the largest
|
||||
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||
// column is the item count, not tons.
|
||||
bulkItemWagonsForAllowedTypes(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
) ||
|
||||
Math.ceil(
|
||||
bookingCargoTons(booking) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
@@ -347,18 +367,64 @@ export function planWagonsWithStock(params: {
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||||
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||||
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||||
// bounded by tonnage AND by the cargo type's items-per-wagon fit.
|
||||
const quantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const perItem =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0;
|
||||
let remainingWeight = roundTons(bookingCargoTons(booking));
|
||||
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||||
let remainingItems = perItem ? quantity : 0;
|
||||
|
||||
/** Whole items one wagon of this slot's type can still take. */
|
||||
const itemRoomOf = (open: OpenSlot): number =>
|
||||
Math.min(
|
||||
open.freeItems ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
|
||||
);
|
||||
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
|
||||
const itemBudgetOf = (open: OpenSlot): number => {
|
||||
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||||
const byTonnage =
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
|
||||
: 1;
|
||||
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||||
};
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Per-item: prefer the type carrying the most whole items per wagon.
|
||||
// openSlot's own capacity sort is stable, so this order breaks its ties.
|
||||
const itemBudgetOfType = (wt: WagonType): number =>
|
||||
Math.min(
|
||||
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
|
||||
: 1,
|
||||
);
|
||||
const orderedCandidates = perItem
|
||||
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
|
||||
: candidates;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
// ponytail: per-item cargo only shares wagons that were opened per-item
|
||||
// (freeItems tracked); mixing itemized and loose loads of one cargo type
|
||||
// on one wagon is not modeled — open a new wagon instead.
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
if (perItem !== (open.freeItems !== undefined)) continue;
|
||||
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
|
||||
if (perItem && takeItems <= 0) continue;
|
||||
const take = perItem
|
||||
? roundTons(takeItems * perItemTons)
|
||||
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
@@ -367,14 +433,39 @@ export function planWagonsWithStock(params: {
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
if (perItem) {
|
||||
open.freeItems = (open.freeItems ?? 0) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
}
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||||
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||||
// preference, so hand it exactly the best in-stock type (full candidate
|
||||
// list only when none has stock, for the proper shortfall message).
|
||||
const inStockBest = perItem
|
||||
? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0)
|
||||
: undefined;
|
||||
const openedSlot = openSlot(
|
||||
inStockBest ? [inStockBest] : orderedCandidates,
|
||||
'BULK',
|
||||
cargoTypeId,
|
||||
leg,
|
||||
);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
let take: number;
|
||||
if (perItem) {
|
||||
// An item heavier than a whole wagon still charges 1 wagon per item
|
||||
// (creation-time validation owns rejecting that case).
|
||||
const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems));
|
||||
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
} else {
|
||||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
}
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
booking.id,
|
||||
@@ -399,6 +490,7 @@ export function planWagonsWithStock(params: {
|
||||
teuPerEdge: [...open.teuPerEdge],
|
||||
covered: { ...open.covered },
|
||||
freeCapacityTons: open.freeCapacityTons,
|
||||
freeItems: open.freeItems,
|
||||
assignedWeightTons: open.slot.assignedWeightTons,
|
||||
allocationCount: open.slot.allocations.length,
|
||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||
@@ -420,6 +512,7 @@ export function planWagonsWithStock(params: {
|
||||
open.teuPerEdge = [...snap.teuPerEdge];
|
||||
open.covered = { ...snap.covered };
|
||||
open.freeCapacityTons = snap.freeCapacityTons;
|
||||
open.freeItems = snap.freeItems;
|
||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||
open.slot.allocations.length = snap.allocationCount;
|
||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||
|
||||
Reference in New Issue
Block a user