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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-08 12:09:40 +03:00
committed by GitHub
8 changed files with 895 additions and 30 deletions

View File

@@ -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).

View File

@@ -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<void> {
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<string>();
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<void> {
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`,
);
}
}

View File

@@ -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()}`,
);
}

View File

@@ -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<BlockingAxis, string> = {
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 (
<Box style={{ flex: 1, minWidth: 150 }}>
<Group justify="space-between" mb={4} wrap="nowrap">
<Group gap={5} wrap="nowrap">
<Icon size={13} color={cardVar(color, 6)} />
<Text size="xs" c="dimmed" fw={600}>
{label}
</Text>
</Group>
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
{fmt(used)}
{cap != null ? ` / ${fmt(cap)}` : ""}
</Text>
</Group>
<Progress
value={pct}
size="md"
radius="xl"
color={near ? color : "edr-green"}
/>
</Box>
);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** 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 (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: selected
? cardVar("edr-green", 3)
: cardVar("gray", 2),
background: selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
: "var(--mantine-color-white)",
opacity: selected ? 1 : 0.92,
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
<ThemeIcon
size={32}
radius="xl"
variant={selected && rank <= 3 ? "filled" : "light"}
color={gov ? "grape" : selected ? "edr-green" : "gray"}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{gov ? (
<Crown size={15} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{gov ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={10} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* score */}
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
{/* wagons + weight this booking adds */}
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
<Text size="xs" c="dimmed" w={64} ta="right">
{fmtTons(booking.weightTons)}
</Text>
{/* verdict */}
<Box w={150} style={{ textAlign: "right" }}>
{selected ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<Sparkles size={11} />}
>
Would board
</Badge>
) : (
<Tooltip
label={
blockedBy
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
: "Below the capacity line"
}
withArrow
>
<Badge variant="light" color="gray" radius="sm">
Waiting list
</Badge>
</Tooltip>
)}
</Box>
</Group>
</Group>
</Paper>
);
}
/** Cut line between the simulated batch and the simulated waiting list. */
function CutLine({ full }: { full: boolean }) {
return (
<Group gap="xs" my={2} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Forecast capacity line{full ? " · TRAIN FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
/**
* 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 (
<Stack gap="lg">
{/* Header + explainer */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
<Group gap="sm">
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
<FlaskConical size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text fw={700}>Forecast batch (simulated)</Text>
<Badge variant="light" color="violet" radius="sm" size="sm">
Preview
</Badge>
</Group>
<Text size="xs" c="dimmed" maw={520}>
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.
</Text>
</Stack>
</Group>
<Group gap="lg">
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="edr-green.7">
{sim.selected.length}
</Text>
<Text size="xs" c="dimmed">
would board
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="gray.7">
{sim.waiting.length}
</Text>
<Text size="xs" c="dimmed">
waiting list
</Text>
</Stack>
</Group>
</Group>
{/* Three capacity axes */}
<Group gap="lg" align="flex-end" wrap="wrap">
<AxisMeter
icon={TrainFront}
label="Wagon slots"
used={sim.usedWagons}
cap={limits.maxWagons}
fmt={(n) => `${n}`}
color="edr-green"
/>
<AxisMeter
icon={Scale}
label="Max pull weight"
used={sim.usedWeightTons}
cap={limits.maxWeightTons}
fmt={fmtTons}
color="orange"
/>
<AxisMeter
icon={Ruler}
label="Train length"
used={sim.usedLengthMeters}
cap={limits.maxLengthMeters}
fmt={fmtMeters}
color="blue"
/>
</Group>
{noCaps ? (
<Alert
color="yellow"
mt="md"
radius="md"
icon={<XCircle size={16} />}
>
No locomotive / capacity limits on this schedule yet forecast can't
draw the capacity line. Assign a locomotive to simulate the fill.
</Alert>
) : null}
</Paper>
{sim.rows.length === 0 ? (
<Paper radius="lg" withBorder p="xl">
<Text c="dimmed" ta="center">
No eligible bookings to forecast yet.
</Text>
</Paper>
) : (
<Stack gap={6}>
{/* WOULD BOARD */}
{sim.selected.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color="edr-green"
>
<Sparkles size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected batch{" "}
<Text span c="dimmed" fw={500}>
({sim.selected.length}) top priority, fits capacity
</Text>
</Text>
</Group>
{sim.selected.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
<CutLine full={sim.full} />
{/* WAITING LIST */}
{sim.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Layers size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected waiting list{" "}
<Text span c="dimmed" fw={500}>
({sim.waiting.length}) boards only if a slot frees up
</Text>
</Text>
</Group>
{sim.waiting.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
{/* INELIGIBLE (expired / pending contract) */}
{sim.ineligible.length > 0 ? (
<Text size="xs" c="dimmed" mt={4}>
{sim.ineligible.length} booking
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
(expired or contract not signed).
</Text>
) : null}
</Stack>
)}
</Stack>
);
}
export default ForecastPanel;

View File

@@ -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 ? (
<SegmentedControl
value={view}
onChange={(v) => setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
<Group gap={6} wrap="nowrap">
<FlaskConical size={13} />
<Text size="xs" fw={600}>
Forecast
</Text>
</Group>
),
},
{
value: "live",
label: (
<Group gap={6} wrap="nowrap">
<Radio size={13} />
<Text size="xs" fw={600}>
Live state
</Text>
</Group>
),
},
]}
/>
) : null;
if (showForecast) {
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
<ForecastPanel data={data} bookings={ranked} />
</Stack>
);
}
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
{/* Header: phase + capacity meter */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md">

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -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.