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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-23 00:02:55 +03:00
committed by GitHub
39 changed files with 3644 additions and 362 deletions

View File

@@ -289,6 +289,7 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
@@ -738,6 +739,7 @@ export class ContractBookingService {
}
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
} as never);

View File

@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
@Type(() => CreateBulkLineDto)
bulkLines?: CreateBulkLineDto[];
@ApiPropertyOptional({
description: 'What the containers carry — captured per booking (container freight).',
})
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
// wagonTypes feed grossBookingWeightTons the REAL tare of the
// wagon type the booking rides — without them it falls back to
// default tares and the workspace gross drifts from the validator.
bookingContainers: { containerType: { wagonTypes: true } },
cargoType: { wagonTypes: true },
},
},
},

View File

@@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record<string, string>;
}) => {
const schedule = {
id: scheduleId,
@@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const yardRepo = {
find: jest
.fn()
.mockResolvedValue(
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
id,
country,
})),
),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
if (entity?.name === 'Yard') return yardRepo;
return genericRepo;
}),
transaction: jest.fn(),
@@ -1264,11 +1276,11 @@ 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.
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
// capacity on the edges they don't ride: a domestic corridor with cargo
// only on m1→m2 still boards bookings on the free first/last edges, so the
// window must stay open for them.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
@@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
// Export b→c holds every wagon of the border crossing: no further export
// can board anywhere (they all must ride that edge), so the window closes —
// while intercity keeps booking the free a→b leg through the per-leg budget.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
// so exports can still board — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,

View File

@@ -24,9 +24,9 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
@@ -60,11 +60,14 @@ import {
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
target.scheduleId,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return false;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
@@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
// The board renders ONE booking window — the schedule's own frozen window
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
loco: LocomotiveLimits | null,
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
s: TrainSchedule,
items: BatchBoardBooking[],
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
return {
scheduleId: s.id,
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
@@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(booking);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
return {
wagons,
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
bookingCargoTons(booking),
wagons,
dims.tareWeightTons,
),
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
* caps deliberately do not apply here (a mis-set global row once capped
* every train at 14m and no export booking could board).
*/
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
locomotive: LocomotiveLimits,
): Promise<void> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
const maxWagons =
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* Built train: FULL when every physical wagon slot is taken — the consist is
* the capacity, weight/length were settled at build time.
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
* pull weight / train length for even one more loaded wagon. The old
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
* could board.
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
* border-crossing edge (which every export/import must ride) can't take one
* more minimal wagon on any axis — slots for built trains (the consist is
* the capacity, weight/length settled at build), all three axes otherwise
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
* Home-side legs may still run empty; intercity ride-alongs keep filling
* them via the per-leg budget and never consult this flag. Domestic routes
* (no border) are full only when every edge is closed.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
@@ -3663,48 +3669,69 @@ 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;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
// every import must cross the ET↔DJ border edge, so once that edge can't
// take one more minimal wagon the booking window may close — even while
// home-side legs still run empty. Intercity ride-alongs never consult this
// flag; they keep booking the free legs through the per-leg budget.
// A single-country (domestic) corridor has no mandatory edge, so it is
// full only when EVERY edge is closed on some axis.
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
if (physicalWagons != null) {
// The consist is the capacity; weight/length were settled at build time.
// remainingBudget swaps in the physical wagon count per edge itself.
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
} else {
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
// No loco, no built train: only the slot axis exists to bind against.
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
limits = await this.capacityLimits(locomotive);
}
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
const minNeed = this.minPerWagonNeed(wagonDims);
const border = await this.borderLeg(budget.stops);
if (border) {
return !budget.fits(
{
wagons: 1,
weightTons: minNeed.grossWeightTons,
lengthMeters: minNeed.lengthMeters,
},
border,
);
}
return budget.isExhausted(minNeed);
}
/**
* 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.
* The corridor's single border-crossing edge (last home-country stop → first
* far-country stop), or null when every stop is in one country. This is the
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
* books — which makes it the train's directional fullness gauge.
*/
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;
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
if (stops.length < 2) return null;
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(stops) } });
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
const first = countryOf.get(stops[0]);
if (!first) return null;
const crossIdx = stops.findIndex((id) => {
const country = countryOf.get(id);
return country != null && country !== first;
});
if (crossIdx <= 0) return null;
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
}
/**

View File

@@ -1,3 +1,4 @@
import { bookingCargoTons } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
}
export function totalAssignedWeight(bookings: Booking[]): number {
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
}

View File

@@ -7,6 +7,7 @@ import {
grossWagonWeightTons,
minLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
expect(limits?.overageToleranceTons).toBe(20);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});
describe('sizePartialOfferWagons', () => {
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2

View File

@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
return Number.isFinite(n) ? n : fallback;
}
/**
* Cargo tons of a booking: the stored VGM total when present, else the sum of
* its container lines (quantity × VGM per unit). The portal's container flow
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
* total alone made every such booking weigh only its tare.
*/
export function bookingCargoTons(booking: {
cargoTotalWeightVgm?: number | string | null;
bookingContainers?: Array<{
quantity?: number | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): number {
const total = num(booking.cargoTotalWeightVgm);
if (total > 0) return total;
return (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
0,
);
}
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);
@@ -260,11 +281,34 @@ export function minLocomotiveLimits(
};
}
function minConfigured(values: Array<number | string | null | undefined>): number {
function minConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? Math.min(...configured) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
locomotive?: LocomotiveLimits | null;
locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
} | null,
): LocomotiveLimits | null {
if (!trainSet) return null;
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((l): l is LocomotiveLimits => Boolean(l));
const pool = linked.length
? linked
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -122,6 +122,7 @@ import {
roundTons,
sumWagonsRequired,
type TrainLimitConfig,
maxEdgeConsistUsage,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
@@ -130,9 +131,12 @@ import {
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
WagonTypeDimensions,
} from './train-capacity.util';
import {
@@ -1706,25 +1710,36 @@ export class TrainSchedulingService {
relations: { wagonType: true },
})
: null;
const planTareTons = consistWagons
const planTareTons = roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const consistTareTons = consistWagons
? roundTons(
consistWagons.reduce(
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
0,
),
)
: roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
: planTareTons;
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
// so their tare rides on top of the binding edge.
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
const edgeUsage = maxEdgeConsistUsage(
wagonPlan,
await this.stopYardsForSchedule(schedule),
);
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
);
}
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
);
}
@@ -4081,9 +4096,6 @@ 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),
);
@@ -4091,12 +4103,13 @@ export class TrainSchedulingService {
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
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]);
}
}
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
// above — the whole-route totals here are informational (summary) only. The
// locomotive checks below also compare the heaviest single edge: a train is
// never heavier than its heaviest leg, so disjoint legs must not be summed.
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
@@ -4119,9 +4132,9 @@ export class TrainSchedulingService {
if (
setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
grossWeightTons ||
maxEdgeGrossTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
maxEdgeLengthMeters)
) {
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
@@ -4140,9 +4153,9 @@ export class TrainSchedulingService {
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
grossWeightTons &&
maxEdgeGrossTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
maxEdgeLengthMeters,
)
) {
pushLimit(['No locomotive can support the total train weight and length']);
@@ -4201,10 +4214,7 @@ export class TrainSchedulingService {
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
locomotive?: LocomotiveLimits | null,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
@@ -6512,7 +6522,7 @@ export class TrainSchedulingService {
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
const cargo = bookingCargoTons(booking);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
@@ -6878,6 +6888,18 @@ export class TrainSchedulingService {
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// Gross ceiling the validator holds each leg to: the set's weakest
// locomotive pull limit plus its overage tolerance. Booking weightTons
// above are gross too, so the strip can sum them per leg against this.
maxGrossWeightTons: (() => {
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
return setLimits
? roundTons(
Number(setLimits.maxPullWeightTons) +
(Number(setLimits.overageToleranceTons) || 0),
)
: null;
})(),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
@@ -6973,7 +6995,10 @@ export class TrainSchedulingService {
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
);
const validation = await this.validateBookingsForScheduling(
previewDto,
@@ -7099,7 +7124,7 @@ export class TrainSchedulingService {
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
trainSetLocomotiveLimits(schedule.trainSet),
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
@@ -7643,7 +7668,7 @@ export class TrainSchedulingService {
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
trainSetLocomotiveLimits(schedule.trainSet),
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;

View File

@@ -9,6 +9,7 @@ import {
containerWagonsForLines,
expandBookingContainerUnits,
expandContainerItems,
maxEdgeConsistUsage,
roundTons,
sumWagonsRequired,
validate20ftContainerRules,
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
expect(containerWagonsForLines([])).toBe(0);
});
});
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
const slot = (
tare: number,
cargo: number,
length: number,
board?: string | null,
alight?: string | null,
) =>
({
tareWeightTons: tare,
assignedWeightTons: cargo,
lengthMeters: length,
boardYardId: board ?? null,
alightYardId: alight ?? null,
}) as never;
const stops = ['a', 'b', 'c'];
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
const plan = [
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
slot(24, 65, 14, 'b', null), // export, rides b→c only
];
// Each edge carries one slot: 89T gross / 14m — never 178T.
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
grossWeightTons: 89,
lengthMeters: 14,
});
});
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
// shared edge a→b carries all 40 slots = 3547.6T gross.
const plan = [
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
];
const usage = maxEdgeConsistUsage(plan, stops);
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
expect(usage.lengthMeters).toBe(560);
});
it('degrades to whole-train totals on a two-stop route', () => {
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
grossWeightTons: 178,
lengthMeters: 28,
});
});
});

View File

@@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge(
stops: string[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const lastIdx = stops.length - 1;
const spans = wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
// A yard missing from the stop list keeps the slot on the whole route.
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
const spans = slotSpans(wagonPlan, stops);
const violations = new Set<string>();
for (let edge = 0; edge < lastIdx; edge += 1) {
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
@@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge(
return [...violations];
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: WagonPlanSlot[],
stops: string[],
): Array<{ from: number; to: number }> {
const lastIdx = stops.length - 1;
return wagonPlan.map((slot) => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
});
}
/**
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
* summed over only the slots riding each edge, maxed across edges. This is the
* figure a locomotive pull/length limit must be compared against — a train is
* never heavier than its heaviest single leg, so summing disjoint legs
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
* Two stops or fewer degrade to the whole-train totals.
*/
export function maxEdgeConsistUsage(
wagonPlan: WagonPlanSlot[],
stops: string[],
): { grossWeightTons: number; lengthMeters: number } {
const totals = (slots: WagonPlanSlot[]) => ({
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
});
if (stops.length <= 2) return totals(wagonPlan);
const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals(
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
);
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
}
return usage;
}
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],