Merge pull request #799 from Tria-plc/freight_feature/usermanagement

add per-container handling options for hazardous, reefer, and return…
This commit is contained in:
marshal
2026-07-18 22:22:46 +03:00
committed by GitHub
28 changed files with 585 additions and 250 deletions

View File

@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
// assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built';
const reservedBooking = (id: string) =>
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
({
id,
freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [],
originYardId: 'yard-a',
destinationYardId: 'yard-b',
originYardId: leg?.origin ?? 'yard-a',
destinationYardId: leg?.dest ?? 'yard-b',
}) as unknown as Booking;
const buildService = (opts: {
physicalWagons: number;
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
}) => {
const schedule = {
id: scheduleId,
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
bookingWindowStatus: 'OPEN',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null,
routeId: opts.routeStops ? 'route-1' : null,
scheduleBookings: [],
trainSet: {
locomotive: {
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
},
};
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
const milestoneRepo = {
find: jest
.fn()
.mockResolvedValue(
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
),
};
const genericRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) =>
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
),
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
return genericRepo;
}),
transaction: jest.fn(),
};
const service = new BookingBatchService(
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
// left the pass-through edges reading "free" in the per-edge budget, so the
// full train's window cycled OPEN forever and the day pool never expired.
// A wagon is committed for the whole trip — leg-free edges are not capacity.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
reserved: [
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,

View File

@@ -3607,11 +3607,19 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// Built train: the physical consist is the only capacity axis, and a wagon
// is committed to its booking for the WHOLE trip — wagon allocation has no
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
// the Doraleh→Negad edge it merely passes through. Count commitments
// train-wide, not per corridor edge: the per-edge budget read "free slots"
// on pass-through legs of a sold-out consist, so the window of a full train
// cycled OPEN forever instead of concluding DONE (and the day pool's
// leftover bookings were never expired).
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
return (await this.committedWagons(schedule)) >= physicalWagons;
}
if ((await this.remainingWagons(schedule)) <= 0) return true;
// Built train: the physical consist is the only capacity axis. Weight and
// length were enforced when the consist was assembled (builder /
// adjust-consist), so a free wagon slot means the train genuinely has room.
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims();
@@ -3620,6 +3628,29 @@ export class BookingBatchService implements OnModuleInit {
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
* regardless of which corridor leg each rides. Deduped by booking id — a
* booking mid-settle can momentarily be both linked and reserved.
*/
private async committedWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const byId = new Map(
[...allocated, ...reserved].map((b) => [b.id, b] as const),
);
let total = 0;
for (const booking of byId.values()) {
total += this.wagonsFor(booking, wagonDims);
}
return total;
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,

View File

@@ -126,15 +126,15 @@ export class IntercityService {
booking.destinationYardId,
);
return {
...this.mapBooking(booking),
...this.mapBooking(booking, need),
need,
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
}),
};
}
@@ -356,7 +356,12 @@ export class IntercityService {
return { schedule, booking };
}
private mapBooking(booking: Booking) {
/**
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
* spent in. Prefer it, so the row's weight sits on the same axis as the
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
*/
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
return {
id: booking.id,
reference: booking.reference,
@@ -372,7 +377,7 @@ export class IntercityService {
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}

View File

@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
@@ -3866,11 +3868,18 @@ export class TrainSchedulingService {
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
// figure spent against it must be gross too — cargo alone under-reports the
// train by the full consist tare and disagrees with the assign path.
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
@@ -3897,7 +3906,7 @@ export class TrainSchedulingService {
if (
setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
totalWeightTons ||
grossWeightTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
) {
@@ -3918,7 +3927,7 @@ export class TrainSchedulingService {
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
totalWeightTons &&
grossWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
)
@@ -3940,6 +3949,9 @@ export class TrainSchedulingService {
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons,
totalTareTons,
// Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length,
@@ -7036,6 +7048,15 @@ export class TrainSchedulingService {
shortfall: 0,
}));
// Gross weight needs the scheduling graph (containers, cargo type, wagon
// types) that the trimmed select above deliberately skips.
const tareDims = await this.loadWagonTareDims();
const fullById = new Map(
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
(b) => [b.id, b],
),
);
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
@@ -7050,6 +7071,11 @@ export class TrainSchedulingService {
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
// GROSS: cargo + tare of the wagons the booking occupies.
grossWeightTons: this.grossBookingWeightTons(
(fullById.get(b.id) ?? b) as Booking,
tareDims,
),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,