add CUSTOMS type to priority configs and update related logic

This commit is contained in:
Marshal
2026-07-07 16:42:57 +00:00
parent f300600bfa
commit b57840c907
26 changed files with 352 additions and 120 deletions

View File

@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Moves service-level priority off the service_types table and onto the
* admin-managed priority_configs table as a new CUSTOMS rule type.
*
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
* null, same as WAGON).
* - Seeds the two customs wagon-count tiers: 110 → 7 pts, 1153 → 15 pts.
* CUSTOMS rules apply only when the booking's service type includesCustoms.
*/
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL) OR
(type = 'CUSTOMS' AND currency IS NULL)
);
`);
await queryRunner.query(`
INSERT INTO freight.priority_configs
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
VALUES
('CUSTOMS', 'With customs 110 wagons', NULL, 1, 10, 7, true, 1),
('CUSTOMS', 'With customs 1153 wagons', NULL, 11, 53, 15, true, 2);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL)
);
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY'));
`);
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
`);
}
}

View File

@@ -21,7 +21,7 @@ export class PriorityConfigsController {
@ApiOperation({ summary: 'List priority configs' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,

View File

@@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreatePriorityConfigDto {
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
@IsIn(['WAGON', 'CURRENCY'])
type!: 'WAGON' | 'CURRENCY';
@ApiProperty({
description: 'Config type: WAGON, CURRENCY, or CUSTOMS',
enum: ['WAGON', 'CURRENCY', 'CUSTOMS'],
})
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
@@ -12,7 +15,8 @@ export class CreatePriorityConfigDto {
label!: string;
@ApiPropertyOptional({
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
description:
'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS',
maxLength: 5,
})
@IsOptional()

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -32,17 +32,6 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
description: 'Priority bonus points awarded when this service is used (015)',
default: 0,
maximum: 15,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(15)
priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['currency', 'type'])
export class PriorityConfig extends BaseEntity {
@Column({ name: 'type', type: 'varchar', length: 20 })
type!: 'WAGON' | 'CURRENCY';
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;

View File

@@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
priorityBonusPoints!: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -176,13 +176,12 @@ export class RuleEngineService {
}
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
if (serviceType) {
priorityScore += serviceType.priorityBonusPoints;
}
const includesCustoms = serviceType?.includesCustoms ?? false;
// Additive priority blocks, each keyed on the booking's total wagon count:
// - WAGON rules apply regardless of currency.
// - CURRENCY rules apply only when the payment currency matches.
// - CUSTOMS rules apply only when the service type includes customs.
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
input.totalWagons >= cfg.minWagonCount &&
@@ -191,7 +190,8 @@ export class RuleEngineService {
for (const cfg of priorityConfigs) {
const applies =
cfg.type === 'WAGON' ||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency);
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
(cfg.type === 'CUSTOMS' && includesCustoms);
if (applies && wagonsInRange(cfg)) {
priorityScore += cfg.scorePoints;
}

View File

@@ -17,7 +17,7 @@ export class PriorityConfigsService {
) {}
async findAll(filter: {
type?: 'WAGON' | 'CURRENCY';
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
isActive?: boolean;
page?: number;
pageSize?: number;
@@ -87,12 +87,15 @@ export class PriorityConfigsService {
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
}
private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void {
private validateCurrencyField(
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
currency: string | undefined | null,
): void {
if (type === 'CURRENCY' && !currency) {
throw new BadRequestException('currency field is required when type is CURRENCY');
}
if (type === 'WAGON' && currency) {
throw new BadRequestException('currency field must be null when type is WAGON');
if (type !== 'CURRENCY' && currency) {
throw new BadRequestException(`currency field must be null when type is ${type}`);
}
}
}

View File

@@ -76,7 +76,6 @@ export class ServiceTypesService {
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder,
});

View File

@@ -3912,14 +3912,16 @@ export class TrainSchedulingService {
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
[companyId],
);
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
// per-row ordering, so re-sort the mapped rows by departure for the client.
return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
return ta - tb;
});
}
@@ -3961,7 +3963,7 @@ export class TrainSchedulingService {
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[contractId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
@@ -3999,7 +4001,7 @@ export class TrainSchedulingService {
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({

View File

@@ -196,7 +196,6 @@ async function ensureReferences(manager: any) {
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
}),

View File

@@ -138,7 +138,6 @@ async function main() {
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
}),

View File

@@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
includesFirstMile: true,
includesLastMile: true,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 10,
},

View File

@@ -295,7 +295,6 @@ export class DemoBookingsSeeder {
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},

View File

@@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder {
includesFirstMile: true,
includesLastMile: true,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 11,
},

View File

@@ -150,16 +150,19 @@ export default function GlCreateBookingForm() {
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
// Next future window across all routes, used for the "next window" notice
// the train dispatching soonest among those not yet open, matching the
// departure-date ordering of the window cards.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
})[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");

View File

@@ -125,16 +125,15 @@ function phaseCountdown(
}
}
/** Drop windows whose booking window (or the train itself) has already passed. */
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
* comparing `Date.now()` here only re-introduced clock skew that made a card
* vanish and reappear on refresh. Trust the server phase (live-patched over the
* socket) instead.
*/
function isPast(w: WindowRow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
function WindowCard({ w }: { w: WindowRow }) {
@@ -286,13 +285,13 @@ export function GlUpcomingWindowsSection({
);
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
// guard above already excludes them; they never reach the upcoming list.
// Open lanes first, then by opening time.
// Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
}, [data]);

View File

@@ -15,11 +15,56 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
// The two carousel window lists share the MyBookingWindow-shaped row and can be
// patched in place. The batch board is a richer, differently-shaped view, so it
// stays on a (debounced) invalidate.
const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]);
/** Shape shared by both carousel window lists (all-lanes + contract-scoped). */
interface WindowRow {
scheduleId: string;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
}
function isWindowKey(key: readonly unknown[]): boolean {
return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1]));
}
/**
* Subscribes to live booking-window pushes for staff. Every phase transition
* the window engine applies invalidates the GL windows carousel and the batch
* board, so both flip the moment the backend does — polling stays only as a
* fallback.
* Fold a server phase push onto a cached window row, recomputing isOpenNow the
* same way the server does (phase OPEN + status OPEN) so live-patched state can
* never disagree with a fresh REST fetch on refresh.
*/
function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent): T {
return {
...row,
windowPhase: event.phase,
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
bookingCycleNo: event.bookingCycleNo,
isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
windowOpensAt: event.windowOpensAt,
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}
/**
* Subscribes to live booking-window pushes for staff. A phase transition carries
* the schedule's full new state; we fold it straight into the carousel window
* lists with setQueriesData rather than invalidating — same rationale as the
* portal hook (no per-push refetch storm; live + refreshed state agree, killing
* the refresh-jump). The batch board is a different-shaped view, so it keeps a
* debounced invalidate, as do pushes for schedules not present in any list.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
@@ -47,19 +92,53 @@ export function useBookingWindowSocket(enabled: boolean = true) {
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "all-booking-windows"],
});
qc.invalidateQueries({
// Coalesce the batch-board refresh (and the unknown-schedule fallback) so a
// burst of pushes triggers at most one invalidation per window.
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRefetch = (includeWindowLists: boolean) => {
if (refetchTimer) return;
refetchTimer = setTimeout(() => {
refetchTimer = null;
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
if (includeWindowLists) {
void qc.invalidateQueries({
predicate: (q) => isWindowKey(q.queryKey),
});
}
}, 800);
};
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(event: BookingWindowPhaseEvent) => {
let patchedSomewhere = false;
qc.setQueriesData<WindowRow[]>(
{ predicate: (q) => isWindowKey(q.queryKey) },
(rows) => {
if (!rows) return rows;
let changed = false;
const next = rows.map((row) => {
if (row.scheduleId !== event.scheduleId) return row;
changed = true;
patchedSomewhere = true;
return applyEvent(row, event);
});
return changed ? next : rows;
},
);
// Always refresh the batch board (different shape, not patched). When the
// schedule wasn't in any window list either, refresh those too so a newly
// announced window surfaces. Both debounced — no per-push stampede.
scheduleRefetch(!patchedSomewhere);
},
);
return () => {
if (refetchTimer) clearTimeout(refetchTimer);
socket.off();
socket.disconnect();
};

View File

@@ -39,7 +39,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
booking.serviceType?.label ??
booking.serviceType?.name ??
booking.serviceType?.code,
serviceTypeBonus: booking.serviceType?.priorityBonusPoints ?? 0,
trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,

View File

@@ -187,6 +187,7 @@ const CURRENCIES = [
const PRIORITY_CONFIG_TYPES = [
{ label: "Wagon count", value: "WAGON" },
{ label: "Payment currency", value: "CURRENCY" },
{ label: "Customs clearance", value: "CUSTOMS" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
@@ -310,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "priority-configs",
label: "Priority Rules",
category: "rules",
subtitle: "Wagon-count and payment-currency scoring rules",
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
searchPlaceholder: "Search priority rules...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
@@ -337,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON"] },
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
@@ -357,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
formFields: [
@@ -367,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -207,7 +207,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -239,7 +239,6 @@ export interface BookingListRow {
priorityScore: number;
schedulingStatus?: string;
serviceTypeLabel?: string;
serviceTypeBonus?: number;
trainScheduleId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;

View File

@@ -8,6 +8,7 @@ import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import type { MyBookingWindow } from "@/services/bookings.service";
function getAuthToken(): string | undefined {
return document.cookie
@@ -20,11 +21,52 @@ function getAuthToken(): string | undefined {
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
// Both window lists live under this key prefix (myBookingWindows +
// contractBookingWindows/*), so one predicate patches every cached list.
const WINDOW_KEY_PREFIX = ["train-scheduling"] as const;
const WINDOW_ACTIONS = new Set(["myBookingWindows", "contractBookingWindows"]);
/**
* Fold a server phase push onto a cached window row. `isOpenNow` is recomputed
* exactly as the server's mapBookingWindowRow does (phase OPEN + status OPEN) so
* the live-patched state can never disagree with what a fresh REST fetch returns
* on refresh — both come from the same server timestamps, not the client clock.
*/
function applyEvent(
row: MyBookingWindow,
event: BookingWindowPhaseEvent,
): MyBookingWindow {
return {
...row,
windowPhase: event.phase,
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
bookingCycleNo: event.bookingCycleNo,
isOpenNow:
event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
windowOpensAt: event.windowOpensAt,
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}
/**
* Subscribes to live booking-window pushes. Every phase transition the window
* engine applies (open, doc review, payment, reopen, done) invalidates the
* cached window lists, so the home-page "Booking Windows" card flips the
* moment the backend does — the 60s poll remains only as a fallback.
* engine applies (open, doc review, payment, reopen, done) carries the schedule's
* full new state; we fold it straight into the cached window lists with
* setQueriesData rather than invalidating.
*
* Why not invalidate: at ~200 concurrent users a namespace-wide broadcast made
* every client refetch two heavy window queries on every schedule transition —
* an O(users × schedules) stampede that lagged the whole population. Patching the
* cache in place means a push costs each client one array map, no network. It
* also fixes the refresh-jump: the live state and a post-refresh REST fetch now
* derive isOpenNow/phase from the same server fields, so they agree.
*
* A push for a schedule not present in any cached list (a brand-new window) can't
* be patched in — those fall back to a debounced invalidate so the new row still
* appears, without the storm.
*/
export function useBookingWindowSocket(enabled: boolean) {
const qc = useQueryClient();
@@ -52,19 +94,59 @@ export function useBookingWindowSocket(enabled: boolean) {
console.debug("[booking-windows] socket disconnected:", reason),
);
// Coalesce the "unknown schedule → refetch" fallback so a burst of pushes
// for new schedules triggers at most one invalidation per window.
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRefetch = () => {
if (refetchTimer) return;
refetchTimer = setTimeout(() => {
refetchTimer = null;
void qc.invalidateQueries({
predicate: (q) => {
const [prefix, action] = q.queryKey as unknown[];
return prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action));
},
});
}, 800);
};
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "myBookingWindows"],
});
qc.invalidateQueries({
queryKey: ["train-scheduling", "contractBookingWindows"],
});
(event: BookingWindowPhaseEvent) => {
let patchedSomewhere = false;
qc.setQueriesData<MyBookingWindow[]>(
{
predicate: (q) => {
const [prefix, action] = q.queryKey as unknown[];
return (
prefix === WINDOW_KEY_PREFIX[0] &&
WINDOW_ACTIONS.has(String(action))
);
},
},
(rows) => {
if (!rows) return rows;
let changed = false;
const next = rows.map((row) => {
if (row.scheduleId !== event.scheduleId) return row;
changed = true;
patchedSomewhere = true;
return applyEvent(row, event);
});
return changed ? next : rows;
},
);
// The schedule wasn't in any cached list — a newly announced window (or a
// lane the client hasn't fetched). Fall back to a debounced refetch so it
// surfaces, without the per-push stampede that patching avoids.
if (!patchedSomewhere) scheduleRefetch();
},
);
return () => {
if (refetchTimer) clearTimeout(refetchTimer);
socket.off();
socket.disconnect();
};

View File

@@ -188,16 +188,15 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
}: UpcomingWindowsSectionProps) {
const [page, setPage] = useState(0);
// Open lanes first, then by opening time — the ones the customer can act on
// lead the carousel.
// Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure so an actionable lane leads.
const sorted = useMemo(
() =>
[...windows].sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
}),
[windows],
);

View File

@@ -110,16 +110,16 @@ function phaseCountdown(
}
}
/** Drop windows whose booking window (or the train itself) has already passed. */
/**
* Drop windows the SERVER considers finished. Keyed off the server's windowPhase
* — never the client clock. The server query already excludes terminal
* (DONE / CLOSED_FOR_DAY) and departed rows; comparing `Date.now()` against the
* row's timestamps here only re-introduced clock skew, which made a card vanish
* on one machine and reappear after refresh. So we trust the phase the server
* sends (live-patched over the socket) and let it drive visibility.
*/
function isPast(w: MyBookingWindow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
function WindowCard({ w }: { w: MyBookingWindow }) {
@@ -293,13 +293,13 @@ export function ContractBookingWindowsSection({
const rows = windows.filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
// Order by the train's dispatch (departure) date, nearest first — the
// shipment leaving soonest leads. Open-now breaks ties on the same departure.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
}, [windows]);

View File

@@ -26,20 +26,22 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
}
/**
* The soonest upcoming (not-yet-open) window with a known opening time, so the
* customer can be told when to come back. Returns `null` when nothing upcoming
* carries an opening time.
* The next upcoming (not-yet-open) window the customer should come back for —
* the one whose train dispatches soonest, so it lines up with the departure-date
* ordering of the cards. Returns `null` when nothing upcoming carries an opening
* time. (`windowOpensAt` is still required so the banner can name a come-back time.)
*/
export function soonestUpcomingWindow(
windows: MyBookingWindow[],
): MyBookingWindow | null {
const upcoming = windows
.filter((w) => !w.isOpenNow && w.windowOpensAt)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
);
.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
});
return upcoming[0] ?? null;
}

View File

@@ -763,7 +763,6 @@ export interface BookingReferenceService {
includesFirstMile: boolean;
includesLastMile: boolean;
includesCustoms: boolean;
priorityBonusPoints: number;
isActive: boolean;
displayOrder: number;
createdAt: string;