mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
Merge pull request #535 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal 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).
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user