diff --git a/apps/edr-freight-api/py/prod-check-invoices-payments.sql b/apps/edr-freight-api/py/prod-check-invoices-payments.sql new file mode 100644 index 000000000..fa94c481d --- /dev/null +++ b/apps/edr-freight-api/py/prod-check-invoices-payments.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- Production DB drift check + fix for the batch/window flow. +-- +-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which +-- queries freight.invoices.payments (a jsonb ledger added by migration +-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on +-- production (snapshot/restore drift — the migration can read as "applied" in +-- freight.migrations while the DDL never took effect), every reserve() throws +-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass, +-- and you see exactly: +-- * only ONE booking gets a pay window (the loop dies after the first reserve +-- whose invoice sync throws), and +-- * reservations never expire cleanly (the settle path hits the same query). +-- +-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2 +-- (idempotent, additive — safe to run even if partially applied). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing, +-- production has the drift and STEP 2 is required. +-- --------------------------------------------------------------------------- +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name IN ( + 'payments', 'subtotal_amount', 'tax_amount', + 'paid_amount', 'balance_amount', 'paid_at' + ) +ORDER BY column_name; +-- Also confirm the enum has the partial-payment statuses: +SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status; +-- Expect ISSUED and PARTIALLY_PAID to be present. + + +-- --------------------------------------------------------------------------- +-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns. +-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so +-- re-running is safe. Wrapped so the enum additions (which cannot run inside a +-- transaction block with immediate use) are applied first, then the columns. +-- --------------------------------------------------------------------------- + +-- Enum values (no-op if they already exist). +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING'; +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID'; + +-- Money-tracking + payments ledger columns. +ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + +-- Backfill derived money fields for existing rows (only rows not already set). +UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount + WHERE subtotal_amount = 0 AND balance_amount = 0; + +UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = COALESCE(paid_at, updated_at) + WHERE status = 'PAID' AND paid_amount = 0; + +-- --------------------------------------------------------------------------- +-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should +-- now be present. After this, deploy the freight_feature/usermanagement branch +-- and the batch will reserve ALL fitting bookings + expire non-payers + top up. +-- --------------------------------------------------------------------------- + + +-- ============================================================================ +-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid +-- invoices.payments can hide OTHER columns the batch flow selects. reserve() +-- and settleReserved() load the FULL Booking entity, so ANY missing booking +-- column throws mid-loop (e.g. we already hit +-- `column Booking.consolidation_resume_status does not exist`). This lists every +-- booking column the entity expects that is MISSING from production — expect +-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied. +-- ============================================================================ +WITH expected(col) AS ( + SELECT unnest(ARRAY[ + 'reference','customer_id','company_id','company_profile_id','is_government', + 'government_institution','train_id','status','contract_id','contract_route_id', + 'booking_type','contract_kind','created_by_role','created_by_user_id', + 'scheduled_date','estimated_shipment_date','expires_at','total_amount', + 'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason', + 'contract_validity_days','contract_valid_from','contract_valid_until', + 'payment_status','contract_type','service_type_id','customs_clearing_enabled', + 'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id', + 'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id', + 'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity', + 'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at', + 'pricing_breakdown','locked_at','priority_score','consolidation_partner_id', + 'consolidation_resume_status','wagons_required','scheduling_status', + 'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id', + 'loaded_at','arrived_at','payment_deadline','selected_for_batch_at', + 'gl_station_yard_id','clearance_current_phase','duty_required', + 'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason', + 'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at' + ]) +) +SELECT e.col AS missing_booking_column +FROM expected e +LEFT JOIN information_schema.columns c + ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col +WHERE c.column_name IS NULL +ORDER BY e.col; +-- If any rows come back, tell me which columns — I'll give you the exact +-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent). 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 2a9169ab7..26f8749be 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 @@ -177,6 +177,8 @@ export interface BatchBoardSchedule { /** Weight committed on the train (allocated + selected-for-batch). */ usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; @@ -290,6 +292,9 @@ export class BookingBatchService implements OnModuleInit { * schedule-scoped — only the fill is day-level). */ async processRouteDay(group: RouteDayGroup): Promise { + this.logger.log( + `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); const scheduleIds = await this.fillRouteDay( group.originYardId, group.destinationYardId, @@ -863,7 +868,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -902,6 +907,7 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, + maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( @@ -918,6 +924,7 @@ export class BookingBatchService implements OnModuleInit { Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + maxWagons: maxWagons ?? null, }; } @@ -956,7 +963,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1033,6 +1040,7 @@ export class BookingBatchService implements OnModuleInit { const pool = await this.bookingsRepository.findBatchPool(scheduleId); const units = this.groupConsolidatedPool(pool); let armed = false; + let reservedThisPass = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing @@ -1087,18 +1095,33 @@ export class BookingBatchService implements OnModuleInit { } } - if (isGov) { - await this.allocate(scheduleId, booking, "gov"); - if (partner) await this.allocate(scheduleId, partner, "gov"); - } else { - await this.reserve(booking, scheduleId); - if (partner) await this.reserve(partner, scheduleId); - armed = true; + // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup) + // can't abort the whole top-up pass and leave the rest to trickle in one + // per tick. Log + skip the failing unit, keep going. + try { + if (isGov) { + await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); + } else { + await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); + armed = true; + } + budget.subtract(need, leg); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` + + `— skipping this unit, continuing: ${(err as Error).message}`, + ); + continue; } - budget.subtract(need, leg); if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } + this.logger.log( + `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); @@ -1190,6 +1213,7 @@ export class BookingBatchService implements OnModuleInit { `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + `poolSize=${pool.length} units=${units.length}`, ); + let reservedThisPass = 0; for (const unit of units) { const { primary: booking, partner } = unit; @@ -1256,17 +1280,35 @@ export class BookingBatchService implements OnModuleInit { continue; } - if (isGov) { - await this.allocate(target.id, booking, "gov"); - if (partner) await this.allocate(target.id, partner, "gov"); - } else { - await this.reserve(booking, target.id); - if (partner) await this.reserve(partner, target.id); - target.armed = true; + // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort + // the whole pass — otherwise only the bookings before the failure get a pay + // window and the rest trickle in one-per-tick on later retries (the + // "selected one at a time / staggered" symptom). Isolate each unit: log + + // skip a failing one, keep reserving the others. The skipped unit stays in + // the pool and is retried next cycle. + try { + if (isGov) { + await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); + } else { + await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); + target.armed = true; + } + target.budget.subtract(need, legOn(target)!); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` + + `— skipping this unit, continuing the batch: ${(err as Error).message}`, + ); } - target.budget.subtract(need, legOn(target)!); } + this.logger.log( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + for (const t of trains) { if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); @@ -1402,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit { const byId = new Map(reserved.map((b) => [b.id, b])); const done = new Set(); let anySettled = false; + this.logger.debug( + `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`, + ); const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; @@ -1448,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const anySettled = await this.settleReserved(scheduleId, false); - if (anySettled) await this.fillSchedule(scheduleId); + // A settle that allocated/expired anything frees or fills capacity → re-run the + // fill so the next waiting-list bookings get a fresh pay window (top-up). + if (anySettled) { + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + await this.fillSchedule(scheduleId); + } } // ---- settle (1h after a batch) ------------------------------------------- @@ -1637,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + this.logger.log( + `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + + `pay by ${deadline.toISOString()}`, + ); // Customer tracking: a wagon slot is reserved and the freight pay window is // open. Doc-trigger path — silent no-op for bookings without milestone rows. void this.completeTrackingMilestones(booking.id, [ @@ -1671,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); }); + this.logger.log( + `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, + ); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); @@ -1738,6 +1798,10 @@ export class BookingBatchService implements OnModuleInit { // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); + this.logger.log( + `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + + `wagons back to the pool for top-up`, + ); } /** @@ -1793,6 +1857,12 @@ export class BookingBatchService implements OnModuleInit { corridorYards, group.day, ); + if (unaccepted.length > 0) { + this.logger.log( + `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` + + `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); + } for (const booking of unaccepted) { await this.bookingsRepository.update(booking.id, { status: "EXPIRED", @@ -1807,8 +1877,7 @@ export class BookingBatchService implements OnModuleInit { .catch(() => undefined); this.notifier.expired(booking); this.logger.log( - `Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` + - `(${group.originYardId}->${group.destinationYardId} ${group.day})`, + `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 0f600d549..0d9087419 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit { now, ); } catch (err) { + // This is THE line to watch when a window freezes mid-phase: the tick + // catches a throw here per-schedule and moves on, so a schedule whose + // transition keeps throwing stays stuck in its phase forever. Log the + // phase + stack so the failing step is obvious. this.logger.error( - `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + `[WINDOW] transition FAILED for schedule ${schedule.id} ` + + `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` + + `${(err as Error).message}`, + ); + this.logger.error( + `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`, ); } } @@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( - `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + + `(cycle ${schedule.bookingCycleNo})`, ); return true; } @@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit { schedule.bookingWindowStatus = 'CLOSED'; } this.logger.log( - `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` + + `review until ${docReviewEndsAt.toISOString()}`, ); return true; } @@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit { // is handled inside the fill (all fit → all reserved → all notified). await this.bookingBatchService.processRouteDay(routeDay); this.logger.log( - `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + + `until ${paymentPhaseEndsAt.toISOString()}`, ); return true; } @@ -287,6 +299,10 @@ export class BookingWindowService implements OnModuleInit { schedule.paymentPhaseEndsAt != null && now >= schedule.paymentPhaseEndsAt ) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` + + `(allocate paid / expire unpaid) then concluding the cycle`, + ); await this.bookingBatchService.settleDueReservations(schedule.id); await this.concludeCycle(schedule, cfg, now); return true; @@ -306,6 +322,9 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'FULL'); await this.setPhase(schedule, { windowPhase: 'DONE' }); await this.tryAutoFinalize(schedule.id); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, + ); return; } @@ -324,7 +343,8 @@ export class BookingWindowService implements OnModuleInit { if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( - `Schedule ${schedule.id} not full but no cycle fits before departure — window done`, + `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + + `departure — window DONE`, ); return; } @@ -347,7 +367,8 @@ export class BookingWindowService implements OnModuleInit { }); const sameDay = eatDay(nextOpensAt) === eatDay(now); this.logger.log( - `Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` + + `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, ); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx new file mode 100644 index 000000000..ecf27c07d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ForecastPanel.tsx @@ -0,0 +1,404 @@ +import { useMemo } from "react"; +import { + Alert, + Badge, + Box, + Group, + Paper, + Progress, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + Crown, + Container, + Boxes, + FlaskConical, + Layers, + Ruler, + Scale, + Sparkles, + TrainFront, + Trophy, + XCircle, +} from "lucide-react"; + +import type { BatchBoardScheduleDetail } from "@/types/trainScheduling"; +import { + simulateBatch, + limitsFromDetail, + type BlockingAxis, + type ForecastRow, +} from "./batchForecast"; + +type Props = { + data: BatchBoardScheduleDetail; + bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"]; +}; + +const cardVar = (color: string, shade: number) => + `var(--mantine-color-${color}-${shade})`; + +const fmtTons = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; +const fmtMeters = (n: number) => + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`; + +const AXIS_LABEL: Record = { + wagons: "wagon slots full", + weight: "over max pull weight", + length: "over train length", +}; + +/** One capacity axis as a labelled meter (used vs cap). */ +function AxisMeter({ + icon: Icon, + label, + used, + cap, + fmt, + color, +}: { + icon: typeof Scale; + label: string; + used: number; + cap: number | null; + fmt: (n: number) => string; + color: string; +}) { + const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0; + const near = pct >= 90; + return ( + + + + + + {label} + + + + {fmt(used)} + {cap != null ? ` / ${fmt(cap)}` : ""} + + + + + ); +} + +function FreightIcon({ type }: { type: string | null }) { + const Icon = type === "BULK" ? Boxes : Container; + return ( + + + + + + ); +} + +/** A single forecast row: rank, booking, capacity contribution, projected verdict. */ +function ForecastCard({ row }: { row: ForecastRow }) { + const { booking, rank, selected, blockedBy } = row; + const gov = booking.isGovernment; + + return ( + + + + + {gov ? ( + + ) : ( + + {rank} + + )} + + + + + {booking.reference} + + + {gov ? ( + + + + + + ) : null} + + + {booking.company} + + + + + + {/* score */} + + + + {booking.priorityScore} + + + {/* wagons + weight this booking adds */} + + + + {booking.wagons}w + + + + {fmtTons(booking.weightTons)} + + {/* verdict */} + + {selected ? ( + } + > + Would board + + ) : ( + + + Waiting list + + + )} + + + + + ); +} + +/** Cut line between the simulated batch and the simulated waiting list. */ +function CutLine({ full }: { full: boolean }) { + return ( + + + + + + + + Forecast capacity line{full ? " · TRAIN FULL" : ""} + + + + + ); +} + +/** + * Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the + * current pool and shows the projected winners + waiting list BEFORE document + * review closes. Not the real selection — the engine commits that when staff run + * the batch after the review window ends. + */ +export function ForecastPanel({ data, bookings }: Props) { + const limits = useMemo(() => limitsFromDetail(data), [data]); + const sim = useMemo( + () => simulateBatch(bookings, limits), + [bookings, limits], + ); + + const noCaps = + limits.maxWagons == null && + limits.maxWeightTons == null && + limits.maxLengthMeters == null; + + return ( + + {/* Header + explainer */} + + + + + + + + + Forecast batch (simulated) + + Preview + + + + What the batch engine would pick if it ran now — greedy fill by + priority until the train is full. The real selection happens when + document review ends and staff run the batch. + + + + + + + {sim.selected.length} + + + would board + + + + + {sim.waiting.length} + + + waiting list + + + + + + {/* Three capacity axes */} + + `${n}`} + color="edr-green" + /> + + + + + {noCaps ? ( + } + > + No locomotive / capacity limits on this schedule yet — forecast can't + draw the capacity line. Assign a locomotive to simulate the fill. + + ) : null} + + + {sim.rows.length === 0 ? ( + + + No eligible bookings to forecast yet. + + + ) : ( + + {/* WOULD BOARD */} + {sim.selected.length > 0 ? ( + + + + + + + Projected batch{" "} + + ({sim.selected.length}) — top priority, fits capacity + + + + {sim.selected.map((r) => ( + + ))} + + ) : null} + + + + {/* WAITING LIST */} + {sim.waiting.length > 0 ? ( + + + + + + + Projected waiting list{" "} + + ({sim.waiting.length}) — boards only if a slot frees up + + + + {sim.waiting.map((r) => ( + + ))} + + ) : null} + + {/* INELIGIBLE (expired / pending contract) */} + {sim.ineligible.length > 0 ? ( + + {sim.ineligible.length} booking + {sim.ineligible.length === 1 ? "" : "s"} not in the forecast + (expired or contract not signed). + + ) : null} + + )} + + ); +} + +export default ForecastPanel; 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 0271d199c..84367fad7 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Box, Group, Paper, Progress, + SegmentedControl, Stack, Text, ThemeIcon, @@ -15,9 +16,11 @@ import { Clock, Container, Crown, + FlaskConical, Hourglass, Layers, ListOrdered, + Radio, TrainFront, Trophy, XCircle, @@ -30,6 +33,8 @@ import type { BatchBoardScheduleDetail, } from "@/types/trainScheduling"; import { WindowPhasePill } from "./batchVisuals"; +import { ForecastPanel } from "./ForecastPanel"; +import { forecastIsLive } from "./batchForecast"; /** * Priority Tracking tab — live, glanceable ranking of every booking on this @@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) { const phase = data.windowPhase; const isPayPhase = phase === "PAYMENT"; + // Before the batch is committed (pre-window / open / doc-review) the real + // selection doesn't exist yet — offer a simulated forecast of who WOULD board. + // Default to it while it's live; let staff flip to the current live state. + const forecastAvailable = forecastIsLive(phase); + const [view, setView] = useState<"forecast" | "live">( + forecastAvailable ? "forecast" : "live", + ); + const showForecast = forecastAvailable && view === "forecast"; + // Rank exactly as the batch engine does: government first, then priority score // desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the // backend uses). The board already returns them in this order, but re-sort @@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) { let rankNo = 0; + const viewToggle = forecastAvailable ? ( + setView(v as "forecast" | "live")} + size="sm" + radius="md" + data={[ + { + value: "forecast", + label: ( + + + + Forecast + + + ), + }, + { + value: "live", + label: ( + + + + Live state + + + ), + }, + ]} + /> + ) : null; + + if (showForecast) { + return ( + + {viewToggle ? {viewToggle} : null} + + + ); + } + return ( + {viewToggle ? {viewToggle} : null} {/* Header: phase + capacity meter */} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts new file mode 100644 index 000000000..f7365feda --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts @@ -0,0 +1,189 @@ +import type { + BatchBoardBookingDetail, + BatchBoardScheduleDetail, +} from "@/types/trainScheduling"; + +/** + * Client-side forecast of what the batch engine WOULD select if it ran right now. + * + * The real selection only happens once the document-review window closes and staff + * hit "run batch". Before that, operations can only see the *current* per-booking + * state (READY / SELECTED / …). This module simulates the engine's greedy fill so + * the board can show the likely winners + waiting list live, during OPEN and + * DOC_REVIEW, before anything is committed. + * + * It mirrors the engine (booking-batch.service): rank government-first, then + * priority score desc, then oldest booked; greedily board each booking while it + * fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and + * train length (metres). The first booking that busts any axis, and everyone after + * it, drops to the waiting list. Purely a projection; the server stays the source + * of truth for the real run. + */ + +export interface ForecastLimits { + /** Wagon-slot cap (schedule.maxWagons), or null if unknown. */ + maxWagons: number | null; + /** Locomotive max pull weight in tons, or null. */ + maxWeightTons: number | null; + /** Max train length in metres, or null. */ + maxLengthMeters: number | null; +} + +/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */ +export type BlockingAxis = "wagons" | "weight" | "length"; + +export interface ForecastRow { + booking: BatchBoardBookingDetail; + /** 1-based rank across the whole eligible pool. */ + rank: number; + /** True → boards in the simulated batch; false → simulated waiting list. */ + selected: boolean; + /** Cumulative wagons/weight/length AFTER this booking (only when selected). */ + cumulativeWagons: number; + cumulativeWeightTons: number; + cumulativeLengthMeters: number; + /** If not selected, the first axis that would have overflowed. */ + blockedBy: BlockingAxis | null; +} + +export interface ForecastResult { + rows: ForecastRow[]; + selected: ForecastRow[]; + waiting: ForecastRow[]; + /** Bookings excluded from the sim entirely (expired / no signed contract). */ + ineligible: BatchBoardBookingDetail[]; + limits: ForecastLimits; + /** Totals of the simulated batch. */ + usedWagons: number; + usedWeightTons: number; + usedLengthMeters: number; + /** True once any axis is at/over its cap — train is "full" in the sim. */ + full: boolean; +} + +/** Engine rank order: government first, then priority desc, then oldest booked. */ +export function rankBookings( + bookings: BatchBoardBookingDetail[], +): BatchBoardBookingDetail[] { + const time = (b: BatchBoardBookingDetail) => + b.fullyExecutedAt + ? new Date(b.fullyExecutedAt).getTime() + : Number.MAX_SAFE_INTEGER; + return [...bookings].sort((a, b) => { + if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; + if (b.priorityScore !== a.priorityScore) + return b.priorityScore - a.priorityScore; + return time(a) - time(b); + }); +} + +/** + * A booking can compete in the batch only once its contract is signed. Expired + * bookings and pending-contract bookings never board, so they're pulled out of the + * sim (surfaced separately so they don't vanish from the board). + */ +function isEligible(b: BatchBoardBookingDetail): boolean { + return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT"; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */ +function overflows(used: number, add: number, cap: number | null): boolean { + return cap != null && used + add > cap; +} + +export function simulateBatch( + bookings: BatchBoardBookingDetail[], + limits: ForecastLimits, +): ForecastResult { + const ranked = rankBookings(bookings); + const eligible = ranked.filter(isEligible); + const ineligible = ranked.filter((b) => !isEligible(b)); + + const rows: ForecastRow[] = []; + let wagons = 0; + let weight = 0; + let length = 0; + // Once the train is full we stop boarding, but keep ranking the rest as waiting. + let full = false; + + eligible.forEach((booking, i) => { + let blockedBy: BlockingAxis | null = null; + if (!full) { + if (overflows(wagons, booking.wagons, limits.maxWagons)) + blockedBy = "wagons"; + else if (overflows(weight, booking.weightTons, limits.maxWeightTons)) + blockedBy = "weight"; + else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters)) + blockedBy = "length"; + } + // Strict fill: the first booking that doesn't fit closes the train, so lower- + // priority bookings can't leapfrog it even if they'd individually fit. Matches + // the engine's greedy pass. + const selected = !full && blockedBy === null; + if (selected) { + wagons += booking.wagons; + weight = round2(weight + booking.weightTons); + length = round2(length + booking.lengthMeters); + } else { + full = true; + } + rows.push({ + booking, + rank: i + 1, + selected, + cumulativeWagons: selected ? wagons : 0, + cumulativeWeightTons: selected ? weight : 0, + cumulativeLengthMeters: selected ? length : 0, + blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)), + }); + }); + + return { + rows, + selected: rows.filter((r) => r.selected), + waiting: rows.filter((r) => !r.selected), + ineligible, + limits, + usedWagons: wagons, + usedWeightTons: weight, + usedLengthMeters: length, + full, + }; +} + +/** When the train closed on an earlier booking, name the tightest axis for the hint. */ +function firstBindingAxis(limits: ForecastLimits): BlockingAxis { + if (limits.maxWagons != null) return "wagons"; + if (limits.maxWeightTons != null) return "weight"; + return "length"; +} + +/** Pull the three capacity caps off the board detail response. */ +export function limitsFromDetail( + data: BatchBoardScheduleDetail, +): ForecastLimits { + return { + maxWagons: data.capacity.maxWagons ?? null, + maxWeightTons: + data.capacity.maxWeightTons ?? + data.locomotive?.maxPullWeightTons ?? + null, + maxLengthMeters: + data.capacity.maxLengthMeters ?? + data.locomotive?.maxTrainLengthMeters ?? + null, + }; +} + +/** + * The forecast is meaningful before the batch is committed — i.e. while bookings + * are still being taken or reviewed. Once the engine has run (PAYMENT onward) the + * real per-booking state is the truth, so we stop showing the projection. + */ +export function forecastIsLive( + phase: BatchBoardScheduleDetail["windowPhase"], +): boolean { + return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW"; +} diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2f0422c6f..ab14a18da 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -283,6 +283,8 @@ export interface BatchBoardSchedule { maxLengthMeters: number | null; usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index d064197d5..e2c7bffcc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -455,8 +455,6 @@ function PriceConfirmModal({ const hasPairingBlock = pairingErrors.length > 0; const capacityErrors = validation?.capacityErrors ?? []; const hasCapacityBlock = capacityErrors.length > 0; - const confirmDisabled = - loading || validationLoading || hasPairingBlock || hasCapacityBlock; // Authoritative server breakdown — the SAME BookingPricingService pass that // prices the booking on create, so it carries every line the booking will be @@ -479,6 +477,17 @@ function PriceConfirmModal({ }; }, [validation, baseTotal]); + // Block confirm until the authoritative server price is in hand. The client + // baseTotal fallback is display-only; booking on it (e.g. after a validation + // error clears validationLoading with no data) would let the customer confirm + // an un-validated, possibly wrong price. + const confirmDisabled = + loading || + validationLoading || + hasPairingBlock || + hasCapacityBlock || + !serverTotal; + // Fallback while the server preview loads: the contract's frozen unit rates // (container/bulk + hazard/reefer only) with the overweight surcharge folded // in. Replaced by the full server breakdown the moment it arrives.