mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
add CUSTOMS type to priority configs and update related logic
This commit is contained in:
@@ -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: 1–10 → 7 pts, 11–53 → 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 1–10 wagons', NULL, 1, 10, 7, true, 1),
|
||||
('CUSTOMS', 'With customs 11–53 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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (0–15)',
|
||||
default: 0,
|
||||
maximum: 15,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(15)
|
||||
priorityBonusPoints?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -196,7 +196,6 @@ async function ensureReferences(manager: any) {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -138,7 +138,6 @@ async function main() {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
|
||||
@@ -295,7 +295,6 @@ export class DemoBookingsSeeder {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
|
||||
@@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -763,7 +763,6 @@ export interface BookingReferenceService {
|
||||
includesFirstMile: boolean;
|
||||
includesLastMile: boolean;
|
||||
includesCustoms: boolean;
|
||||
priorityBonusPoints: number;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: string;
|
||||
|
||||
Reference in New Issue
Block a user