mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1083,7 +1083,7 @@ export class TrainSchedulingService {
|
||||
{
|
||||
schedulingStatus: SchedulingStatus.Scheduled,
|
||||
scheduledAt,
|
||||
wagonsRequired: sumWagonsRequired(booking),
|
||||
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user