diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d63106c2b..00cf55e17 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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 { + async computeSubmitPriorityScore( + booking: Booking, + totalWagonsOverride?: number, + ): Promise { 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; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a20ef600d..60e2b6767 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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 = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 93227f3ab..cf710e429 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { + 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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 44886f116..104db4545 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index e1b4064bb..cd0411831 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 79adf8d8f..372b29357 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b72194299..953698ae0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1083,7 +1083,7 @@ export class TrainSchedulingService { { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt, - wagonsRequired: sumWagonsRequired(booking), + wagonsRequired: sumWagonsRequired(booking, wagonPlan), }, manager, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index e180b482b..19e19dca3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -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 }]); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 30a46a099..2af606cdc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -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; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 00b0abd87..b02ebbc8f 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -207,9 +207,9 @@ function RankedCard({ {/* Wagons */} - {/* + {booking.wagons}w - */} + {/* 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) { {data.capacity.allocatedWagons} allocated ·{" "} {capUsed} in batch + {maxWagons != null ? ` · ${maxWagons} max` : ""} + {/* 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. */} 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" /> 0 + (maxWagons ?? capUsed) > 0 ? Math.min( 100, - ((capUsed - data.capacity.allocatedWagons) / capUsed) * 100, + ((capUsed - data.capacity.allocatedWagons) / + (maxWagons ?? capUsed)) * + 100, ) : 0 } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index a150978d4..558f15ab4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,7 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - // Boxes, + Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef[] = [ const b = row.original; return ( - {/* + {b.wagons}w - */} + {fmtTons(b.weightTons)} @@ -890,14 +890,14 @@ export default function BatchScheduleDetailPage() {