Merge pull request #1072 from Tria-plc/dev

Syncing
This commit is contained in:
Nathnael Wondisha
2026-08-02 20:16:41 +03:00
committed by GitHub
123 changed files with 19344 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { createRequire } from "node:module";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
*/
const JSON_BODY_LIMIT = '20mb';
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
* connection only — so TLS still validates against the certificate's CN.
*
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
* namespace object is frozen, so assigning to it is silently dropped and the
* patch becomes a no-op. `require` returns the live module object every other
* caller (minio's http agent included) reads `lookup` off.
*/
function applyDnsHostOverrides(): void {
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
if (!raw) return;
const overrides = new Map<string, string>();
for (const entry of raw.split(",")) {
const [host, ip] = entry.split("=").map((part) => part?.trim());
if (host && ip) overrides.set(host.toLowerCase(), ip);
}
if (overrides.size === 0) return;
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
const originalLookup = dns.lookup.bind(dns);
// `dns.lookup` is overloaded (options optional, all/family variants); the
// cast keeps that surface intact while we intercept only mapped hostnames.
(dns as { lookup: unknown }).lookup = ((
hostname: string,
options: unknown,
callback?: unknown,
) => {
const ip = overrides.get(hostname?.toLowerCase?.());
if (!ip) return (originalLookup as Function)(hostname, options, callback);
const done = (typeof options === "function" ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number,
) => void;
const family = ip.includes(":") ? 6 : 4;
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
process.nextTick(() =>
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
);
}) as typeof dns.lookup;
console.log(
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
);
}
applyDnsHostOverrides();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);

View File

@@ -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

View File

@@ -290,6 +290,7 @@ export class ContractBookingService {
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
@@ -773,6 +774,7 @@ export class ContractBookingService {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
@@ -1253,6 +1255,7 @@ export class ContractBookingService {
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
@@ -1297,11 +1300,7 @@ export class ContractBookingService {
return;
}
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const requested = this.resolveBulkTons(dto);
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
@@ -1344,8 +1343,10 @@ export class ContractBookingService {
}
}
} else {
// PER_ITEM contracts are capped in items, so the item count is the
// consumption figure — tonnage is only wagon-sizing data.
const requested =
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0;
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
@@ -1373,11 +1374,7 @@ export class ContractBookingService {
}
}
} else {
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const requested = this.resolveBulkTons(dto);
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
@@ -1641,11 +1638,27 @@ export class ContractBookingService {
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
if (!dto.bulkLines?.length) return 0;
return dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
(sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0),
0,
);
}
/**
* Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item
* count `cargoTotalWeightVgm` holds. Both are needed: the item count prices
* the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives
* per-item weight from tonnage ÷ items). Null for PER_TON bulk, where
* `cargoTotalWeightVgm` already IS the tonnage.
*/
private resolveBulkWeightTons(
dto: CreateBookingUnderContractDto,
): number | null {
const lines = dto.bulkLines ?? [];
if (!lines.some((l) => Number(l.itemCount) > 0)) return null;
const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0);
return tons > 0 ? tons : null;
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
@@ -1961,6 +1974,7 @@ export class ContractBookingService {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>

View File

@@ -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(
@@ -3131,6 +3143,14 @@ export class BookingBatchService implements OnModuleInit {
async intercityCapacity(scheduleId: string): Promise<{
budget: CorridorBudget;
needFor: (booking: Booking) => Capacity;
/**
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
* schedule's own wagon stock — so the same booking reads differently on a
* different train. Empty when the stock can't be resolved.
*/
breakdownFor: (
booking: Booking,
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -3145,7 +3165,24 @@ export class BookingBatchService implements OnModuleInit {
// still accepts ride-alongs on its empty legs — that is the whole point
// of the ride-along flow.
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
// Physical stock of THIS schedule's train (built consist, or the yard fleet
// it will draw from) — what makes the breakdown train-specific.
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return {
budget,
needFor: (booking) => this.needFor(booking, wagonDims),
breakdownFor: (booking) =>
this.wagonBreakdownFor(
booking,
wagonDims,
stock.remainingByTypeId,
stock.codesByTypeId,
),
};
}
/**
@@ -4094,6 +4131,78 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
*
* `wagonsFor` sizes the booking on ONE representative type (the first the
* cargo type allows), which is all the abstract budget needs. Staff placing a
* ride-along need the physical picture: how many of each type this schedule
* must actually give up. So each allowed type is sized on its OWN capacity and
* items-fit, then filled greedily from the type with the largest per-wagon
* take, bounded by what the schedule has left of it.
*
* Because the stock is per-schedule, the same booking breaks down differently
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
* the booking's types are unconfigured or the train stocks none of them — the
* caller then shows the plain total.
*/
private wagonBreakdownFor(
booking: Booking,
wagonDims: WagonDims,
stockByTypeId: Map<string, number>,
codesByTypeId: Map<string, string>,
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
const total = this.wagonsFor(booking, wagonDims);
if (total <= 0) return [];
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
// that swallows more of the booking per wagon needs fewer wagons.
const options = this.allowedDimsWithTypes(booking, wagonDims)
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => {
const wagonTypeId = o.wagonTypeId as string;
const wagonsIfAlone = Math.max(
1,
bulkItemWagonsRequired(
booking,
o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
) ||
(o.dims.capacityTons > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
: total),
);
return {
wagonTypeId,
code: codesByTypeId.get(wagonTypeId) ?? '—',
available: stockByTypeId.get(wagonTypeId) ?? 0,
// Share of the whole booking one wagon of this type carries.
takePerWagon: 1 / wagonsIfAlone,
};
})
.sort((a, b) => b.takePerWagon - a.takePerWagon);
if (!options.length) return [];
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
let remaining = 1;
for (const option of options) {
if (remaining <= 1e-9) break;
const wagons = Math.min(
option.available,
Math.ceil(remaining / option.takePerWagon),
);
if (wagons <= 0) continue;
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
remaining -= wagons * option.takePerWagon;
}
// The train cannot hold the whole booking in the types it stocks — the
// `fits` check already fails it; report only what it CAN take.
return out;
}
private fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&

View File

@@ -0,0 +1,108 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The intercity ride-along board shows WHICH wagon types a booking takes from
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
* wagons. Because the split is drawn against that schedule's own stock, the
* same booking must read differently on a different train.
*/
describe('BookingBatchService — intercity wagon breakdown', () => {
const N35 = 'wagon-type-n35';
const PW2 = 'wagon-type-pw2';
const dims = (capacityTons: number) => ({
capacityTons,
lengthMeters: 14,
tareWeightTons: 20,
});
const wagonDims = {
bulk: dims(60),
container: dims(60),
byWagonTypeId: new Map([
[N35, dims(60)],
[PW2, dims(20)],
]),
};
const codes = new Map([
[N35, 'N35'],
[PW2, 'PW2'],
]);
/**
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
*/
const perItemBooking = {
id: 'booking-1',
freightType: 'BULK',
cargoTotalWeightVgm: 400,
bulkTotalWeightTons: 800,
bookingContainers: [],
cargoType: {
wagonTypes: [{ id: N35 }, { id: PW2 }],
itemsPerWagonMap: {},
},
} as unknown as Booking;
const service = Object.create(
BookingBatchService.prototype,
) as BookingBatchService;
const breakdown = (
booking: Booking,
stock: Map<string, number>,
): Array<{ code: string; wagons: number }> =>
(
service as unknown as {
wagonBreakdownFor: (
b: Booking,
d: typeof wagonDims,
s: Map<string, number>,
c: Map<string, string>,
) => Array<{ code: string; wagons: number }>;
}
)
.wagonBreakdownFor(booking, wagonDims, stock, codes)
.map(({ code, wagons }) => ({ code, wagons }));
it('takes the highest-capacity type first when the train stocks plenty', () => {
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
});
it('falls back to the smaller type for the remainder when the big one runs short', () => {
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
expect(rows[1].code).toBe('PW2');
expect(rows[1].wagons).toBeGreaterThan(0);
});
it('reads differently on a train that stocks only the small type', () => {
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
});
it('honours the configured items-per-wagon fit over raw tonnage', () => {
// Floor space binds before weight: an N35 physically holds 20 of these
// items even though 30 would fit by weight → 20 wagons, not 14.
const floorBound = {
...perItemBooking,
cargoType: {
wagonTypes: [{ id: N35 }],
itemsPerWagonMap: { [N35]: 20 },
},
} as unknown as Booking;
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
{ code: 'N35', wagons: 20 },
]);
});
it('returns nothing when the train stocks none of the allowed types', () => {
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
});
});

View File

@@ -155,12 +155,17 @@ export class IntercityService {
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
};
}),
};
}
@@ -200,7 +205,9 @@ export class IntercityService {
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
cargoType: true,
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
// check must size the booking exactly as the candidate list did.
cargoType: { wagonTypes: true },
},
});
if (!booking) {
@@ -326,6 +333,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
@@ -355,6 +366,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)

View File

@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
).rejects.toThrow(/free only 5/);
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })
.effectiveWagonsRequired(booking);
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
// ceil(20/4) = 5 wagons.
const perItemBooking = (wagonsRequired: number | null) => ({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired,
cargoType: {
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
itemsPerWagonMap: { 'wt-nw5': 4 },
},
});
it('overrides a stale too-small stamp with the item-aware recompute', () => {
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
expect(effective(perItemBooking(1))).toBe(5);
});
it('keeps a stored stamp that is at least the recompute', () => {
expect(effective(perItemBooking(7))).toBe(7);
});
it('trusts the stamp when BULK cargo relations are not loaded', () => {
expect(
effective({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired: 5,
cargoType: null,
}),
).toBe(5);
});
});
});

View File

@@ -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);
}
@@ -7819,7 +7828,7 @@ export class TrainSchedulingService {
*/
private effectiveWagonsRequired(booking: Booking): number {
const stored = Number(booking.wagonsRequired);
if (stored > 0) return Math.ceil(stored);
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
@@ -7827,7 +7836,15 @@ export class TrainSchedulingService {
booking.freightType === 'BULK' && bulkCapacities.length
? Math.max(...bulkCapacities)
: undefined;
return wagonsRequiredForBooking(booking, bulkCapacity);
// BULK with no cargo relations loaded: recomputing would size against a
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
return storedCeil;
}
// Stored is a candidate, never an early return (batch parity): rows
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
}
/**

View File

@@ -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]);
});
});

View File

@@ -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) => {