mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
Merge pull request #584 from Tria-plc/freight_feature/usermanagement
Enhance bulk booking handling and wagon capacity calculations across …
This commit is contained in:
@@ -400,8 +400,18 @@ export class BookingPricingService {
|
|||||||
* All three components are produced by RuleEngineService.evaluate, so submit
|
* All three components are produced by RuleEngineService.evaluate, so submit
|
||||||
* simply re-runs the engine — there is no extra submit-time inflation.
|
* 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);
|
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);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
return ruleResult.priorityScore;
|
return ruleResult.priorityScore;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||||
} as never,
|
} as never,
|
||||||
{ emitPhase: jest.fn() } 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,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
{ findOpenOffer: jest.fn() } as never,
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
@@ -585,6 +587,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
{ findOpenOffer: jest.fn() } as never,
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
@@ -615,6 +618,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
{ findOpenOffer: jest.fn() } as never,
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
@@ -745,6 +749,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
null as never,
|
null as never,
|
||||||
null as never,
|
null as never,
|
||||||
null as never,
|
null as never,
|
||||||
|
null as never,
|
||||||
) as unknown as {
|
) as unknown as {
|
||||||
wagonsFor(booking: unknown, dims: unknown): number;
|
wagonsFor(booking: unknown, dims: unknown): number;
|
||||||
};
|
};
|
||||||
@@ -780,6 +785,13 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40);
|
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', () => {
|
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.
|
// Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each.
|
||||||
const booking = {
|
const booking = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -13,6 +15,7 @@ import { DataSource, In } from 'typeorm';
|
|||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
@@ -42,6 +45,7 @@ import {
|
|||||||
bookingGrossWeightTons,
|
bookingGrossWeightTons,
|
||||||
bookingTrainLengthMeters,
|
bookingTrainLengthMeters,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
|
sizePartialOfferWagons,
|
||||||
trainHardCaps,
|
trainHardCaps,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
@@ -239,6 +243,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
@Inject(forwardRef(() => BookingPricingService))
|
||||||
|
private readonly pricingService: BookingPricingService,
|
||||||
|
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
@Optional() private readonly splitService?: BookingSplitService,
|
@Optional() private readonly splitService?: BookingSplitService,
|
||||||
@@ -1097,6 +1103,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
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);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
let reservedThisPass = 0;
|
let reservedThisPass = 0;
|
||||||
@@ -1314,6 +1324,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
corridorYards,
|
corridorYards,
|
||||||
day,
|
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 partners collapse into one atomic unit (both-or-neither); a
|
||||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
@@ -1505,11 +1519,24 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
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(
|
const sized = await this.splitService.sizeOffer(
|
||||||
booking,
|
booking,
|
||||||
budget.wagons,
|
partial.wagons,
|
||||||
need.wagons,
|
need.wagons,
|
||||||
bulkCapacityTons,
|
bulkCapacityTons,
|
||||||
|
partial.maxCargoTons,
|
||||||
);
|
);
|
||||||
if (!sized) return null;
|
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
|
* Wagons a booking occupies. Two axes bind independently and the booking needs
|
||||||
* enough wagons to satisfy BOTH, so the count is the larger of:
|
* 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.
|
* That under-reported the board and let the fill loop overbook the train.
|
||||||
*/
|
*/
|
||||||
private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
|
private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
|
||||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
// Stored wagonsRequired is a candidate, never an early return: rows written
|
||||||
return Math.ceil(booking.wagonsRequired);
|
// 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
|
// 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.
|
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||||
@@ -2311,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const byWeight =
|
const byWeight =
|
||||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
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(
|
private capacityFor(
|
||||||
|
|||||||
@@ -55,12 +55,17 @@ export class BookingSplitService {
|
|||||||
* Size the largest part of the booking that fits `freeWagons`, priced via an
|
* Size the largest part of the booking that fits `freeWagons`, priced via an
|
||||||
* in-memory clone. Returns null when nothing meaningful fits (no whole
|
* in-memory clone. Returns null when nothing meaningful fits (no whole
|
||||||
* container unit / no bulk tonnage, or pricing failed).
|
* 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(
|
async sizeOffer(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
freeWagons: number,
|
freeWagons: number,
|
||||||
totalWagons: number,
|
totalWagons: number,
|
||||||
bulkWagonCapacityTons: number,
|
bulkWagonCapacityTons: number,
|
||||||
|
maxOfferedWeightTons?: number,
|
||||||
): Promise<SizedOffer | null> {
|
): Promise<SizedOffer | null> {
|
||||||
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
|
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
|
||||||
|
|
||||||
@@ -110,10 +115,15 @@ export class BookingSplitService {
|
|||||||
if (!offeredLines.length || offeredWagons <= 0) return null;
|
if (!offeredLines.length || offeredWagons <= 0) return null;
|
||||||
clone.bookingContainers = clonedContainers;
|
clone.bookingContainers = clonedContainers;
|
||||||
} else {
|
} 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);
|
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
|
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;
|
if (offeredWeightTons <= 0) return null;
|
||||||
offeredWagons = Math.min(
|
offeredWagons = Math.min(
|
||||||
freeWagons,
|
freeWagons,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
grossWagonWeightTons,
|
grossWagonWeightTons,
|
||||||
minLocomotiveLimits,
|
minLocomotiveLimits,
|
||||||
|
sizePartialOfferWagons,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
|
|
||||||
describe('train-capacity.util', () => {
|
describe('train-capacity.util', () => {
|
||||||
@@ -185,4 +186,54 @@ describe('train-capacity.util', () => {
|
|||||||
expect(limits?.maxPullWeightTons).toBe(3500);
|
expect(limits?.maxPullWeightTons).toBe(3500);
|
||||||
expect(limits?.overageToleranceTons).toBe(20);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -256,6 +256,42 @@ export function bookingGrossWeightTons(
|
|||||||
return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
|
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: {
|
export function wagonTypeDimensionsFromEntity(wt: {
|
||||||
lengthMeters?: number | string | null;
|
lengthMeters?: number | string | null;
|
||||||
capacityTons?: number | string | null;
|
capacityTons?: number | string | null;
|
||||||
|
|||||||
@@ -1083,7 +1083,7 @@ export class TrainSchedulingService {
|
|||||||
{
|
{
|
||||||
schedulingStatus: SchedulingStatus.Scheduled,
|
schedulingStatus: SchedulingStatus.Scheduled,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
wagonsRequired: sumWagonsRequired(booking),
|
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,29 @@ describe('wagon-plan.util', () => {
|
|||||||
expect(plan).toHaveLength(2);
|
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)', () => {
|
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
||||||
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
|
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
|
||||||
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||||
|
|||||||
@@ -436,7 +436,21 @@ export function expandContainerItems(
|
|||||||
return items;
|
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') {
|
if (booking.freightType === 'BULK') {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,9 +207,9 @@ function RankedCard({
|
|||||||
{/* Wagons */}
|
{/* Wagons */}
|
||||||
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
|
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
|
||||||
<TrainFront size={13} color={cardVar("gray", 6)} />
|
<TrainFront size={13} color={cardVar("gray", 6)} />
|
||||||
{/* <Text fw={700} size="sm">
|
<Text fw={700} size="sm">
|
||||||
{booking.wagons}w
|
{booking.wagons}w
|
||||||
</Text> */}
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* State chip / pay countdown */}
|
{/* State chip / pay countdown */}
|
||||||
@@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
|||||||
}, [bookings]);
|
}, [bookings]);
|
||||||
|
|
||||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||||
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
|
// Wagon-slot cap from the board DTO (derived from train length and the
|
||||||
// capacity line shows the wagons currently committed rather than a hard cap.
|
// shortest wagon type); null on legacy rows without a computable cap.
|
||||||
const maxWagons: number | null = null;
|
const maxWagons: number | null = data.capacity.maxWagons ?? null;
|
||||||
|
|
||||||
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
|
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
|
||||||
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
|
// 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}>
|
<Text size="xs" fw={700}>
|
||||||
{data.capacity.allocatedWagons} allocated ·{" "}
|
{data.capacity.allocatedWagons} allocated ·{" "}
|
||||||
{capUsed} in batch
|
{capUsed} in batch
|
||||||
|
{maxWagons != null ? ` · ${maxWagons} max` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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.Root size="lg" radius="xl">
|
||||||
<Progress.Section
|
<Progress.Section
|
||||||
value={
|
value={
|
||||||
capUsed > 0
|
(maxWagons ?? capUsed) > 0
|
||||||
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
|
? Math.min(
|
||||||
|
100,
|
||||||
|
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
|
||||||
|
)
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
/>
|
/>
|
||||||
<Progress.Section
|
<Progress.Section
|
||||||
value={
|
value={
|
||||||
capUsed > 0
|
(maxWagons ?? capUsed) > 0
|
||||||
? Math.min(
|
? Math.min(
|
||||||
100,
|
100,
|
||||||
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
|
((capUsed - data.capacity.allocatedWagons) /
|
||||||
|
(maxWagons ?? capUsed)) *
|
||||||
|
100,
|
||||||
)
|
)
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
// Boxes,
|
Boxes,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
|||||||
const b = row.original;
|
const b = row.original;
|
||||||
return (
|
return (
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
{/* <Badge variant="default" radius="sm" size="sm">
|
<Badge variant="default" radius="sm" size="sm">
|
||||||
{b.wagons}w
|
{b.wagons}w
|
||||||
</Badge> */}
|
</Badge>
|
||||||
<Badge variant="default" radius="sm" size="sm">
|
<Badge variant="default" radius="sm" size="sm">
|
||||||
{fmtTons(b.weightTons)}
|
{fmtTons(b.weightTons)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -890,14 +890,14 @@ export default function BatchScheduleDetailPage() {
|
|||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
items={[
|
items={[
|
||||||
// {
|
{
|
||||||
// label: "Allocated wagons",
|
label: "Allocated wagons",
|
||||||
// value: data.capacity.maxWagons
|
value: data.capacity.maxWagons
|
||||||
// ? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||||
// : data.capacity.allocatedWagons,
|
: data.capacity.allocatedWagons,
|
||||||
// hint: "on this train",
|
hint: "on this train",
|
||||||
// icon: Boxes,
|
icon: Boxes,
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
label: "Train length",
|
label: "Train length",
|
||||||
value: data.capacity.maxLengthMeters
|
value: data.capacity.maxLengthMeters
|
||||||
@@ -1111,7 +1111,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||||
freightType="CONTAINER"
|
freightType={scheduleDetailQuery.data.freightType ?? null}
|
||||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||||
totalLengthMeters={
|
totalLengthMeters={
|
||||||
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
||||||
@@ -1133,7 +1133,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
<TrainConsistView
|
<TrainConsistView
|
||||||
scheduleDetail={scheduleDetailQuery.data}
|
scheduleDetail={scheduleDetailQuery.data}
|
||||||
scheduleId={scheduleId ?? ""}
|
scheduleId={scheduleId ?? ""}
|
||||||
maxWagons={53}
|
maxWagons={data.capacity.maxWagons ?? 53}
|
||||||
highlightBookingId={selectedBookingId}
|
highlightBookingId={selectedBookingId}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user