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

Enhance bulk booking handling and wagon capacity calculations across …
This commit is contained in:
marshal
2026-07-09 18:31:00 +03:00
committed by GitHub
11 changed files with 278 additions and 32 deletions

View File

@@ -400,8 +400,18 @@ export class BookingPricingService {
* All three components are produced by RuleEngineService.evaluate, so submit
* simply re-runs the engine — there is no extra submit-time inflation.
*/
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
async computeSubmitPriorityScore(
booking: Booking,
totalWagonsOverride?: number,
): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking);
// BULK bookings have no container lines, so buildEvalInputForBooking yields
// totalWagons = 0 and every wagon-range priority config misses. The batch
// engine derives a bulk booking's wagon footprint from tonnage vs. live
// wagon capacity and passes it here to score the booking properly.
if (totalWagonsOverride != null && totalWagonsOverride > 0) {
evalInput.totalWagons = totalWagonsOverride;
}
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
return ruleResult.priorityScore;
}

View File

@@ -136,6 +136,7 @@ describe('BookingBatchService — PAID reconcile', () => {
expirePayable: jest.fn().mockResolvedValue(undefined),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
);
});
@@ -563,6 +564,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -585,6 +587,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -615,6 +618,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -745,6 +749,7 @@ describe('BookingBatchService — wagonsFor', () => {
null as never,
null as never,
null as never,
null as never,
) as unknown as {
wagonsFor(booking: unknown, dims: unknown): number;
};
@@ -780,6 +785,13 @@ describe('BookingBatchService — wagonsFor', () => {
expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40);
});
it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => {
// Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the
// DB; trusting them charged one tare for the whole consist (700 + 25.2
// instead of 700 + 10 × 25.2 gross).
expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10);
});
it('takes the binding axis for containers: weight can exceed TEU geometry', () => {
// Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each.
const booking = {

View File

@@ -1,6 +1,8 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -13,6 +15,7 @@ import { DataSource, In } from 'typeorm';
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';
@@ -42,6 +45,7 @@ import {
bookingGrossWeightTons,
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
@@ -239,6 +243,8 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Inject(forwardRef(() => BookingPricingService))
private readonly pricingService: BookingPricingService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@@ -1097,6 +1103,10 @@ export class BookingBatchService implements OnModuleInit {
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let reservedThisPass = 0;
@@ -1314,6 +1324,10 @@ export class BookingBatchService implements OnModuleInit {
corridorYards,
day,
);
// BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
@@ -1505,11 +1519,24 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
// The wagon-slot axis alone under-constrains the offer. On a weight- or
// length-limited train (slots to spare, but e.g. only 798T of pull weight
// left) sizing by slots either produced an offer the fits() check below
// rejected, or — when the free slots exceeded the booking's own wagon
// count — sizeOffer refused outright, so a bulk booking on a weight-bound
// train was never offered a split at all. Size across all three axes.
const perWagon =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon);
if (!partial) return null;
const sized = await this.splitService.sizeOffer(
booking,
budget.wagons,
partial.wagons,
need.wagons,
bulkCapacityTons,
partial.maxCargoTons,
);
if (!sized) return null;
@@ -2284,6 +2311,56 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* Stamp real priority scores on the pool's BULK bookings before the batch
* ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk
* booking has no container lines to carry a wagon count), so every
* wagon-range priority config missed and bulk import bookings entered the
* batch at score 0 — they were never prioritized. Their wagon footprint is
* derivable from tonnage vs. live wagon capacity (wagonsFor), so the score
* is computed here — when doc review closes and the batch runs — and
* persisted so the priority board shows the same ranking. The pool arrives
* SQL-ordered by the old scores; the caller must re-sort after this.
*/
private async recomputeBulkPriorities(
pool: Booking[],
wagonDims: WagonDims,
): Promise<void> {
for (const booking of pool) {
if (booking.freightType !== 'BULK') continue;
try {
const wagons = this.wagonsFor(booking, wagonDims);
const score = await this.pricingService.computeSubmitPriorityScore(
booking,
wagons,
);
if (Number(booking.priorityScore ?? 0) === score) continue;
await this.dataSource
.getRepository(Booking)
.update(booking.id, { priorityScore: score });
booking.priorityScore = score;
} catch (err) {
// A failed recompute keeps the stored score — never blocks the batch.
this.logger.warn(
`Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` +
`${(err as Error).message}`,
);
}
}
}
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
private resortPoolByPriority(pool: Booking[]): void {
pool.sort(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
a.createdAt.getTime() - b.createdAt.getTime(),
);
}
/**
* Wagons a booking occupies. Two axes bind independently and the booking needs
* enough wagons to satisfy BOTH, so the count is the larger of:
@@ -2298,9 +2375,14 @@ export class BookingBatchService implements OnModuleInit {
* That under-reported the board and let the fill loop overbook the train.
*/
private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);
}
// Stored wagonsRequired is a candidate, never an early return: rows written
// while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and
// trusting them charged one tare for a whole bulk consist (a 700T booking on
// 70T wagons read 700 + 1 tare instead of 700 + 10 tares).
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
@@ -2311,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
return Math.max(DEFAULT_WAGONS_PER_BOOKING, byLength, byWeight);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
}
private capacityFor(

View File

@@ -55,12 +55,17 @@ export class BookingSplitService {
* Size the largest part of the booking that fits `freeWagons`, priced via an
* in-memory clone. Returns null when nothing meaningful fits (no whole
* container unit / no bulk tonnage, or pricing failed).
*
* `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a
* weight-limited train the wagons' own tare eats into the locomotive's
* remaining pull weight, so the caller passes the room left after tare.
*/
async sizeOffer(
booking: Booking,
freeWagons: number,
totalWagons: number,
bulkWagonCapacityTons: number,
maxOfferedWeightTons?: number,
): Promise<SizedOffer | null> {
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
@@ -110,10 +115,15 @@ export class BookingSplitService {
if (!offeredLines.length || offeredWagons <= 0) return null;
clone.bookingContainers = clonedContainers;
} else {
// Bulk: split by weight — the offered part is what freeWagons can carry.
// Bulk: split by weight — the offered part is what freeWagons can carry,
// further capped by the caller's weight room when the pull limit binds.
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
offeredWeightTons = Math.min(
totalWeight,
freeWagons * bulkWagonCapacityTons,
maxOfferedWeightTons ?? Number.POSITIVE_INFINITY,
);
if (offeredWeightTons <= 0) return null;
offeredWagons = Math.min(
freeWagons,

View File

@@ -6,6 +6,7 @@ import {
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
sizePartialOfferWagons,
} from './train-capacity.util';
describe('train-capacity.util', () => {
@@ -185,4 +186,54 @@ describe('train-capacity.util', () => {
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
});
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
// wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty
// of slots/length. The boundary 1000T booking (15 wagons) must be offered
// the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling
// 542.4T of cargo — gross exactly 744.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 744, lengthMeters: 500 },
15,
pw2,
);
expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 });
});
it('still sizes by wagon slots when they bind first (legacy behavior)', () => {
const offer = sizePartialOfferWagons(
{ wagons: 3, weightTons: 100000, lengthMeters: 100000 },
15,
pw2,
);
expect(offer?.wagons).toBe(3);
});
it('sizes by the LENGTH axis when it binds first', () => {
// 60m of train left → 3 PW2 (17.066m) fit, the 4th does not.
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 100000, lengthMeters: 60 },
15,
pw2,
);
expect(offer?.wagons).toBe(3);
});
it('never offers all of the booking — a split is a strict subset', () => {
const offer = sizePartialOfferWagons(
{ wagons: 40, weightTons: 100000, lengthMeters: 100000 },
15,
pw2,
);
expect(offer?.wagons).toBe(14);
});
it('returns null when not even one part-loaded wagon fits the weight room', () => {
expect(
sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2),
).toBeNull();
});
});
});

View File

@@ -256,6 +256,42 @@ export function bookingGrossWeightTons(
return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
}
/**
* Size a partial (split-on-payment) offer against the room left on a train,
* across ALL THREE capacity axes — not just wagon slots. Each wagon adds
* `capacityTons` of payload headroom but its own tare spends the same weight
* room the cargo needs, so on a weight-limited train more wagons is not always
* more cargo. Scans wagon counts (the last wagon may run part-loaded) and
* returns the count that maximizes the cargo carried, with the cargo cap the
* caller should apply. Null when not even one part-loaded wagon fits. The
* offer is a strict subset of the booking: never all `bookingWagons`.
*/
export function sizePartialOfferWagons(
room: { wagons: number; weightTons: number; lengthMeters: number },
bookingWagons: number,
perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number },
): { wagons: number; maxCargoTons: number } | null {
const maxByLength =
perWagon.lengthMeters > 0
? Math.floor(room.lengthMeters / perWagon.lengthMeters)
: room.wagons;
const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1);
let wagons = 0;
let bestCargoTons = 0;
for (let w = 1; w <= ceiling; w += 1) {
const cargoAt = Math.min(
w * perWagon.capacityTons,
room.weightTons - w * perWagon.tareWeightTons,
);
if (cargoAt > bestCargoTons) {
bestCargoTons = cargoAt;
wagons = w;
}
}
if (wagons < 1) return null;
return { wagons, maxCargoTons: round3(room.weightTons - wagons * perWagon.tareWeightTons) };
}
export function wagonTypeDimensionsFromEntity(wt: {
lengthMeters?: number | string | null;
capacityTons?: number | string | null;

View File

@@ -1083,7 +1083,7 @@ export class TrainSchedulingService {
{
schedulingStatus: SchedulingStatus.Scheduled,
scheduledAt,
wagonsRequired: sumWagonsRequired(booking),
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
},
manager,
);

View File

@@ -82,6 +82,29 @@ describe('wagon-plan.util', () => {
expect(plan).toHaveLength(2);
});
it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => {
// 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired
// must carry all of them so gross weight charges 12 tares downstream.
const booking = {
id: 'bulk-700',
reference: 'bulk-700',
freightType: 'BULK',
cargoTotalWeightVgm: 700,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([booking], cw3);
expect(plan).toHaveLength(12);
expect(sumWagonsRequired(booking, plan)).toBe(12);
// Without a plan the pre-plan fallback still applies.
expect(sumWagonsRequired(booking)).toBe(1);
});
it('counts container wagons from the plan TEU packing', () => {
const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(sumWagonsRequired(booking, plan)).toBe(3);
});
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);

View File

@@ -436,7 +436,21 @@ export function expandContainerItems(
return items;
}
export function sumWagonsRequired(booking: Booking): number {
/**
* Wagons a booking actually occupies. Prefer counting the built wagon plan's
* slots that carry one of the booking's allocations — for BULK that is its
* tonnage spread over real wagons (a 700T booking on 70T wagons rides 10
* wagons, and downstream gross-weight math charges 10 tares, not 1). Without
* a plan there is no capacity to divide by, so fall back to the pre-plan
* estimates: 1 for bulk, the lines' stored counts for containers.
*/
export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number {
const occupiedSlots = (wagonPlan ?? []).filter((slot) =>
slot.allocations.some((allocation) => allocation.bookingId === booking.id),
).length;
if (occupiedSlots > 0) {
return occupiedSlots;
}
if (booking.freightType === 'BULK') {
return 1;
}

View File

@@ -207,9 +207,9 @@ function RankedCard({
{/* Wagons */}
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
{/* <Text fw={700} size="sm">
<Text fw={700} size="sm">
{booking.wagons}w
</Text> */}
</Text>
</Group>
{/* State chip / pay countdown */}
@@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
}, [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
// capacity line shows the wagons currently committed rather than a hard cap.
const maxWagons: number | null = null;
// Wagon-slot cap from the board DTO (derived from train length and the
// shortest wagon type); null on legacy rows without a computable cap.
const maxWagons: number | null = data.capacity.maxWagons ?? null;
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
@@ -431,23 +431,31 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
<Text size="xs" fw={700}>
{data.capacity.allocatedWagons} allocated ·{" "}
{capUsed} in batch
{maxWagons != null ? ` · ${maxWagons} max` : ""}
</Text>
</Group>
{/* Scale against the real wagon cap when the DTO carries one; fall back
to the in-batch total on legacy rows without a computable cap. */}
<Progress.Root size="lg" radius="xl">
<Progress.Section
value={
capUsed > 0
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
(maxWagons ?? capUsed) > 0
? Math.min(
100,
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
)
: 0
}
color="edr-green"
/>
<Progress.Section
value={
capUsed > 0
(maxWagons ?? capUsed) > 0
? Math.min(
100,
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
((capUsed - data.capacity.allocatedWagons) /
(maxWagons ?? capUsed)) *
100,
)
: 0
}

View File

@@ -20,7 +20,7 @@ import {
AlertTriangle,
ArrowLeft,
ArrowLeftRight,
// Boxes,
Boxes,
CalendarDays,
CheckCircle2,
ClipboardCheck,
@@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
{/* <Badge variant="default" radius="sm" size="sm">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge> */}
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
@@ -890,14 +890,14 @@ export default function BatchScheduleDetailPage() {
<KpiStrip
items={[
// {
// label: "Allocated wagons",
// value: data.capacity.maxWagons
// ? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
// : data.capacity.allocatedWagons,
// hint: "on this train",
// icon: Boxes,
// },
{
label: "Allocated wagons",
value: data.capacity.maxWagons
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
{
label: "Train length",
value: data.capacity.maxLengthMeters
@@ -1111,7 +1111,7 @@ export default function BatchScheduleDetailPage() {
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
freightType={scheduleDetailQuery.data.freightType ?? null}
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={
scheduleDetailQuery.data.trainSet?.totalLengthMeters
@@ -1133,7 +1133,7 @@ export default function BatchScheduleDetailPage() {
<TrainConsistView
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
maxWagons={53}
maxWagons={data.capacity.maxWagons ?? 53}
highlightBookingId={selectedBookingId}
/>
</Box>