From b57840c90758df66e5a43617c10546be751d7467 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 7 Jul 2026 16:42:57 +0000 Subject: [PATCH] add CUSTOMS type to priority configs and update related logic --- .../2000000000000-AddCustomsPriorityConfig.ts | 83 ++++++++++++++ .../priority-configs.controller.ts | 2 +- .../dto/create-priority-config.dto.ts | 12 ++- .../dto/create-service-type.dto.ts | 13 +-- .../entities/priority-config.entity.ts | 2 +- .../entities/service-type.entity.ts | 3 - .../rule-engine/rule-engine.service.ts | 8 +- .../services/priority-configs.service.ts | 11 +- .../services/service-types.service.ts | 1 - .../train-scheduling.service.ts | 12 ++- .../scripts/seed-gate-pass-train-scenarios.ts | 1 - .../seed-negad-indode-arrived-train.ts | 1 - ...ved-first-lastmile-demo-bookings.seeder.ts | 1 - .../src/seed/demo-bookings.seeder.ts | 1 - .../paid-import-export-mile-demo.seeder.ts | 1 - .../contracts/GlCreateBookingForm.tsx | 15 +-- .../contracts/GlUpcomingWindowsSection.tsx | 29 +++-- .../bookingWindows/useBookingWindowSocket.ts | 101 +++++++++++++++-- .../features/bookings/mapBookingListRow.ts | 1 - .../src/pages/ruleEngine/config/resources.ts | 7 +- .../backoffice/src/types/booking.ts | 3 +- .../bookingWindows/useBookingWindowSocket.ts | 102 ++++++++++++++++-- .../components/UpcomingWindowsSection.tsx | 13 ++- .../ContractBookingWindowsSection.tsx | 30 +++--- .../src/pages/contracts/booking-window.ts | 18 ++-- packages/types/src/freight/index.ts | 1 - 26 files changed, 352 insertions(+), 120 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts new file mode 100644 index 000000000..c7009c303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 36b863dd6..8dcc58e77 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -21,7 +21,7 @@ export class PriorityConfigsController { @ApiOperation({ summary: 'List priority configs' }) findAll(@Query() query: Record) { 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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts index d2ca44d93..484d3fbaf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index d68625fdc..a8e030bba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts index df60b3ea1..e1fa5bfa7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index 2b7cb3f23..b882f1a08 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 0fee8e75a..e451098fc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 173c63f21..6d7034ad4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -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}`); } } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 2ad8753c3..6608749d1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -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, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 4ccd123cc..8a8492a6b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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({ diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a57ce84c7..9aa9e169c 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -196,7 +196,6 @@ async function ensureReferences(manager: any) { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }), diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index ff4a34493..3ebcca6ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -138,7 +138,6 @@ async function main() { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }), diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index b67f1f572..989e18bf1 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 10, }, diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index be5e1c87d..c42d831bc 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -295,7 +295,6 @@ export class DemoBookingsSeeder { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }, diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 732653a9d..e1c46d168 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 11, }, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 6f52d287f..547e9ce9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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(""); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index cfb79fbcb..3e3aa27ad 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -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]); diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index 3941c487c..50c68b185 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -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(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 | 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( + { 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(); }; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index a5bc34170..bc10fd064 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 20b632710..e59af5f5d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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" }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 41c4a155a..dc9a29db8 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -207,7 +207,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; 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; diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 5b91af82e..6df836357 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -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 | 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( + { + 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(); }; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index 6dcece7ef..391e7665b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -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], ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index f9b3c3420..51125194c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -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]); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts index 44b97c741..b7a1e8c60 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -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; } diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 33a091576..7817e0a31 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -763,7 +763,6 @@ export interface BookingReferenceService { includesFirstMile: boolean; includesLastMile: boolean; includesCustoms: boolean; - priorityBonusPoints: number; isActive: boolean; displayOrder: number; createdAt: string;