From 1121e9ce82d466aba141c0488ad4bc3f5377859a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 21:51:59 +0000 Subject: [PATCH] Implement clearance-first booking flow and completion process for customs contracts --- .../booking-transition.clearance.spec.ts | 7 +- .../modules/bookings/clearance.util.spec.ts | 6 +- .../src/modules/bookings/clearance.util.ts | 10 +- .../booking-batch.service.spec.ts | 49 +++++++ .../train-scheduling/booking-batch.service.ts | 123 +++++++++++++++- .../booking-journey.service.ts | 9 +- .../booking-notifier.service.ts | 16 +++ .../booking-window.service.spec.ts | 6 + .../booking-window.service.ts | 7 + .../warehouses/warehouse-inventory.service.ts | 16 ++- .../warehouse-scheduling-adapter.service.ts | 8 +- .../contracts/GlCreateBookingForm.tsx | 15 +- .../ContractCustomerAction.tsx | 136 +++++++++++++----- .../components/ClearanceCard.tsx | 54 +++++-- .../components/StatusHero.tsx | 23 ++- .../clearance/BookingActionButton.tsx | 19 ++- .../bookings/clearance/BookingActionModal.tsx | 13 +- .../bookings/clearance/bookingNextAction.ts | 42 +++++- .../src/pages/contracts/booking-window.ts | 29 ++-- 19 files changed, 482 insertions(+), 106 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 72c83e136..890ae6344 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => { serviceType: { includesCustoms: false }, // no output set → only the input gate }; - // Input set has two required docs. + // Input set has two required docs. Non-customs bookings resolve to the + // ONE_TIME self-clearance document set. const inputSetting = { - code: 'clearance_import_container_without_customs', + code: 'contract_clearance_selfclear_import_container', fields: [ { fileKey: 'commercial_invoice', isRequired: true }, { fileKey: 'packing_list', isRequired: true }, @@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( */ describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { const inputSetting = { - code: 'clearance_import_container_without_customs', + code: 'contract_clearance_selfclear_import_container', fields: [ { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index ac21b2dce..969de5583 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => { expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( 'clearance_import_container_with_customs', ); + // Non-customs bookings self-clear with the same document set a ONE_TIME + // self-clear contract uses. expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( - 'clearance_import_container_without_customs', + 'contract_clearance_selfclear_import_container', ); }); @@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => { 'clearance_export_bulk_with_customs', ); expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( - 'clearance_export_bulk_without_customs', + 'contract_clearance_selfclear_export_bulk', ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 6d7b86c8f..1cc6503df 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -29,8 +29,14 @@ export function clearanceSettingCode( const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); - const customs = includesCustoms ? 'with_customs' : 'without_customs'; - return `clearance_${op}_${freight}_${customs}`; + // Non-customs (Path A) bookings self-clear: the customer proves his own + // clearance with the SAME smaller document set a ONE_TIME self-clear + // contract uses (customs declaration, release permit, …) — not the + // GL-oriented booking sets. + if (!includesCustoms) { + return `contract_clearance_selfclear_${op}_${freight}`; + } + return `clearance_${op}_${freight}_with_customs`; } /** The GL-output (customs output) setting code, keyed on op + freight. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 24e908761..a87b22c3e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -700,6 +700,16 @@ describe('BookingBatchService — PAID reconcile', () => { bookingsRepository.findBatchPoolByCorridorDay .mockResolvedValueOnce([waiting]) .mockResolvedValue([]); + // expire()'s paid-guard and reserve()'s idempotency guard both re-read the + // booking fresh — answer with the matching row, not the paidBooking default + // (which would make the guard rescue-allocate the lapsed reservation). + const byId: Record = { lapsed, waiting }; + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + byId[opts?.where?.id ?? ''] ?? null, + ); await service.settleDueReservations(trainId); @@ -725,6 +735,14 @@ describe('BookingBatchService — PAID reconcile', () => { return Promise.resolve(reads === 1 ? [lapsed] : []); }); bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // expire()'s paid-guard re-reads the booking fresh — answer with the + // (unpaid) lapsed row, not the paidBooking default. + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + opts?.where?.id === 'lapsed' ? lapsed : null, + ); await Promise.all([ service.settleDueReservations(trainId), @@ -733,6 +751,37 @@ describe('BookingBatchService — PAID reconcile', () => { expect(notifier.expired).toHaveBeenCalledTimes(1); }); + + it('never expires a reservation whose payment landed — allocates it instead', async () => { + const latePaid = booking('late-paid', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([latePaid]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // The payment webhook flipped paymentStatus between the settle's list + // read and expire()'s fresh re-read — the deadline had already passed. + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + opts?.where?.id === 'late-paid' + ? { ...latePaid, paymentStatus: 'PAID' } + : null, + ); + + await service.settleDueReservations(trainId); + + // Money was taken → the booking boards. Never expired. + expect(notifier.expired).not.toHaveBeenCalled(); + expect(notifier.secured).toHaveBeenCalledTimes(1); + expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith( + [{ trainScheduleId: trainId, bookingId: 'late-paid' }], + expect.anything(), + ); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d51108043..cda6de85f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit { where: { id: bookingId }, relations: { company: true }, }); - if (!booking?.trainScheduleId) return; + if (!booking) return; + if (!booking.trainScheduleId) { + // A paid booking with no train is money taken and nothing boarding — + // scream so staff pin it to a schedule manually (batch board / assign). + if (booking.paymentStatus === "PAID" || booking.status === "PAID") { + this.logger.error( + `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + + `its reservation was likely expired before the payment landed. ` + + `Assign it to a schedule manually from the batch board.`, + ); + } + return; + } const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || @@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit { * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train * it failed to pay for — it's back in the day pool for staff to act on. + * `reason` picks the customer message: 'payment' (pay window lapsed) or + * 'no-capacity' (no train on the chosen day could take the booking). + * + * PAID GUARD: a booking whose payment has landed is never expired — money was + * taken, so it boards, even when the webhook arrived after the deadline or the + * settle read a stale row. It allocates onto the train it was selected for; if + * the wagon planner then finds no physical wagon, the booking stays linked and + * staff assign wagons manually. Consolidated bookings are exempt from the + * rescue: the shared wagon is both-or-neither, and settleReserved owns that + * pair decision. */ - private async expire(booking: Booking): Promise { + private async expire( + booking: Booking, + reason: "payment" | "no-capacity" = "payment", + ): Promise { + if (!booking.consolidationPartnerId) { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: booking.id }, relations: { company: true } }); + const paid = + fresh != null && + (fresh.paymentStatus === "PAID" || fresh.status === "PAID"); + const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId; + if (paid && paidScheduleId) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — payment already ` + + `landed; allocating on schedule ${paidScheduleId} instead`, + ); + await this.allocate(paidScheduleId, fresh, "paid"); + return; + } + } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, @@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit { // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); - this.notifier.expired(booking); + if (reason === "no-capacity") { + this.notifier.expiredNoCapacity(booking); + } else { + this.notifier.expired(booking); + } this.logger.log( - `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + - `wagons back to the pool for top-up`, + `[BATCH] EXPIRED ${booking.reference} — ` + + (reason === "no-capacity" + ? "no train on its day had capacity left" + : "payment window passed; freed its wagons back to the pool for top-up"), ); } + /** + * End-of-day sweep: once a schedule's window cycle concludes and NO other + * train on the same route-day can still run a cycle, the waiting pool for + * that day is dead — a FULLY_EXECUTED booking left in it would wait forever. + * Expire every leftover commercial booking and tell the customers to rebook + * another day. Government bookings are never auto-expired (they preempt). + * Returns how many bookings were expired. + */ + async expireLeftoverDayPool(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const day = eatDay(schedule.scheduledDepartureDate); + const group: RouteDayGroup = { + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day, + }; + + // Another train on this route-day that can still take bookings keeps the + // pool alive — when IT concludes, its own sweep runs this check again. + const siblings = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const anotherTrainStillOpen = siblings.some( + (s) => + s.id !== schedule.id && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.windowPhase !== "DONE" && + s.bookingWindowStatus !== "FULL", + ); + if (anotherTrainStillOpen) return 0; + + const corridorYards = await this.corridorYardsForRouteDay(group); + const pool = corridorYards.length + ? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day) + : await this.bookingsRepository.findBatchPoolByRouteDay( + group.originYardId, + group.destinationYardId, + day, + ); + const leftovers = pool.filter((b) => !b.isGovernment); + for (const booking of leftovers) { + await this.expire(booking, "no-capacity"); + } + if (leftovers.length) { + this.logger.log( + `[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` + + `expired ${leftovers.length} waiting booking(s)`, + ); + } + return leftovers.length; + } + /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 2df741bd3..426bd37be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -14,6 +14,7 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; @@ -139,7 +140,7 @@ export class BookingJourneyService { .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .innerJoin( - 'freight.train_schedule_bookings', + TrainScheduleBooking, 'tsb', 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', { scheduleId }, @@ -218,8 +219,10 @@ export class BookingJourneyService { const bookings = await this.dataSource .getRepository(Booking) .createQueryBuilder('booking') + // Entity-class join: a raw 'freight.table' string is parsed by TypeORM as + // an alias.property path ("freight" alias was not found) — runtime 500. .innerJoin( - 'freight.train_schedule_bookings', + TrainScheduleBooking, 'tsb', 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', { scheduleId }, @@ -350,7 +353,7 @@ export class BookingJourneyService { .createQueryBuilder('alloc') .innerJoinAndSelect('alloc.trainSetWagon', 'slot') .innerJoin( - 'freight.train_schedules', + TrainSchedule, 'schedule', 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', { scheduleId }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index c189825fd..edd807152 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -141,6 +141,22 @@ export class BookingNotifierService { this.inApp(b, 'Payment window expired', msg); } + /** + * Every train on the booking's chosen day filled up (or no further train runs) + * before the waiting list reached this booking — it expired unplaced. HIGH so + * the customer hears about it by email/SMS and rebooks another day. + */ + expiredNoCapacity(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` + + `is full and no other train is scheduled that day. The booking has expired — ` + + `please rebook for another day. No re-approval is needed.`; + void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)'); + this.inApp(b, 'No capacity — booking expired', msg, { + priority: NotificationPriority.HIGH, + }); + } + scheduleFull(b: Booking): void { this.logger.warn( `SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 3286da0eb..72229cfd6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -19,6 +19,7 @@ describe('BookingWindowService — window state machine', () => { isScheduleFull: jest.Mock; hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; + expireLeftoverDayPool: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -73,6 +74,7 @@ describe('BookingWindowService — window state machine', () => { // No reservation is mid-pay-window by default, so the cycle concludes. hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), + expireLeftoverDayPool: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -186,6 +188,8 @@ describe('BookingWindowService — window state machine', () => { expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL'); expect(s.windowPhase).toBe('DONE'); expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId); + // The day's leftover waiting list is swept once this train is done. + expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => { @@ -210,6 +214,8 @@ describe('BookingWindowService — window state machine', () => { }); await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z')); expect(s.windowPhase).toBe('DONE'); + // No further train can run for this day → leftover waiting list is swept. + expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('no transition fires before its deadline (idempotent tick)', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index a730b6aa6..25a7b1aed 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -357,6 +357,10 @@ export class BookingWindowService implements OnModuleInit { this.logger.log( `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, ); + // This train is done. If no other train on the route-day can still take + // the waiting list, those bookings have nowhere to go — expire + notify + // them now instead of leaving them FULLY_EXECUTED forever. + await this.bookingBatchService.expireLeftoverDayPool(schedule.id); return; } @@ -390,6 +394,9 @@ export class BookingWindowService implements OnModuleInit { `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + `departure — window DONE`, ); + // No further cycle on this train. Same sweep as the FULL branch: if no + // sibling train can still take the day's waiting list, expire + notify. + await this.bookingBatchService.expireLeftoverDayPool(schedule.id); return; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8f7a71fc1..f13a8c78f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2,7 +2,11 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Company } from '../companies/entities/company.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; @@ -3651,23 +3655,25 @@ export class WarehouseInventoryService { .leftJoinAndSelect('inv.warehouse', 'warehouse') .leftJoinAndSelect('inv.yard', 'yard') .leftJoinAndSelect('inv.zone', 'zone') - .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') - .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') + // Entity-class joins: TypeORM parses a raw 'freight.table' string as an + // alias.property path ("freight" alias was not found) — runtime 500. + .leftJoin(Booking, 'booking', 'booking.id = inv.booking_id') + .leftJoin(Company, 'company', 'company.id = booking.company_id') .leftJoin( - 'freight.containers', + Container, 'container', `((inv.container_id IS NOT NULL AND container.id = inv.container_id) OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id)) AND container.deleted_at IS NULL`, ) .leftJoin( - 'freight.cargoes', + Cargo, 'cargo', `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id)) AND cargo.deleted_at IS NULL`, ) - .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') + .leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') .addSelect('booking.reference', 'b_reference') .addSelect('company.name', 'c_name') .addSelect('container.container_number', 'ct_number') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts index dcf685c9f..49bcc996f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Route } from '../routes/entities/route.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; /** @@ -50,9 +52,11 @@ export class WarehouseSchedulingAdapterService { .leftJoinAndSelect('inv.warehouse', 'warehouse') .leftJoinAndSelect('inv.yard', 'yard') .leftJoinAndSelect('inv.zone', 'zone') - .innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + // Entity-class joins: TypeORM parses a raw 'freight.table' string as an + // alias.property path ("freight" alias was not found) — runtime 500. + .innerJoin(Booking, 'booking', 'booking.id = inv.booking_id') .innerJoin( - 'freight.routes', + Route, 'route', 'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)', { routeId }, 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 bb94fea08..a520c35e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -198,18 +198,17 @@ export default function GlCreateBookingForm() { ); // 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. + // the next moment booking OPENS (chronological), which may belong to a + // later-departing train. Departure-first ordering here named the soonest + // train's later opening as "next" while another lane opened earlier. const nextWindow = useMemo(() => { const now = Date.now(); return (bookingWindows ?? []) .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) - .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]; + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), + )[0]; }, [bookingWindows]); const [scheduledDate, setScheduledDate] = useState(""); diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 9e73dcb58..86c37585c 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -1,7 +1,14 @@ -import { Button, Group, type ButtonProps } from "@mantine/core"; +import { + Button, + Group, + Modal, + Text, + ThemeIcon, + type ButtonProps, +} from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { LucideIcon } from "lucide-react"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; @@ -57,7 +64,9 @@ export function ContractCustomerAction({ } if (action.type === "pay") { - return ; + return ( + + ); } if (action.type === "initiate") { @@ -118,6 +127,7 @@ export function InitiateBookingButton({ }) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); const mutation = useMutation({ mutationFn: () => @@ -137,6 +147,7 @@ export function InitiateBookingButton({ toast.success( "Booking initiated — upload your clearance documents to start the review.", ); + setConfirmOpen(false); navigate(`/bookings/${booking.id}`); }, onError: (e: Error) => @@ -144,37 +155,87 @@ export function InitiateBookingButton({ }); return ( - + <> + { + if (!mutation.isPending) setConfirmOpen(false); + }} + centered + radius="lg" + size="md" + closeOnClickOutside={!mutation.isPending} + closeOnEscape={!mutation.isPending} + withCloseButton={!mutation.isPending} + title={ + + + + + Initiate a new booking? + + } + > + + This creates a new shipment booking under contract{" "} + + {contract.reference} + + . You'll upload the clearance documents next, and the shipment + quantity is drawn down from your contract's reserved capacity. + + + + + + + + ); } @@ -191,7 +252,12 @@ export function ContractCustomerActionCell({ return ( {docButton} - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 5bbece55e..dcc455ada 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -1,6 +1,13 @@ import { useState } from "react"; import { Alert, Button, Group, Text } from "@mantine/core"; -import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react"; +import { + CheckCircle2, + ClipboardList, + Clock, + PackagePlus, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout"; /** * Customer-facing clearance section on the booking detail page: a compact - * status summary with a single action button. The document grid, re-uploads, - * and the shipment-day picker all live in the shared {@link BookingActionModal} - * (the same modal the My Shipments list uses), so the flow behaves identically - * from both entry points. + * status summary with a single action button. The document grid and re-uploads + * live in the shared {@link BookingActionModal} (the same modal the My + * Shipments list uses); a finished bare instance instead shows a "Book" button + * that navigates to the booking form. */ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [modalOpen, setModalOpen] = useState(false); + const navigate = useNavigate(); const status = booking.status as string; const action = getBookingNextAction(booking); + // BOOK: clearance finished on a bare instance — go straight to the booking + // form (cargo + shipment day + window check) instead of opening the modal. + const isBookAction = action?.kind === "BOOK" && Boolean(action.to); if (status === "OPERATION_REQUESTED") { return ( @@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const summary = status === "CLEARANCE_READY" ? ( }> - Clearance is complete. Pick a shipment day and proceed to operation. + {isBookAction + ? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window." + : "Clearance is complete. Pick a shipment day and proceed to operation."} ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( }> @@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { @@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { {summary} - Use “{action?.label ?? "the action button"}” to manage your clearance - documents. + {isBookAction + ? "Use “Book” to enter the cargo details and schedule your shipment." + : `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`} - setModalOpen(false)} - /> + {!isBookAction && ( + setModalOpen(false)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 82c692a90..ba8ca3db7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,6 +66,10 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); + // Contract-drawdown instance in the clearance gate: it was INITIATED with one + // click (no cargo/date yet), not submitted through the wizard. + const isInitiatedInstance = + status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId); // Legacy bookings never reach the ARRIVED status — they light up the Arrival // stage from the train's ARRIVED state while staying IN_TRANSIT, so the // headline is overridden here. Bookings with a per-booking journey carry the @@ -78,7 +82,14 @@ export function StatusHero({ "Your shipment reached its destination yard and is being unloaded and prepared for release.", stage, } - : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); + : isInitiatedInstance + ? { + title: "Booking initiated — clearance documents needed", + description: + "Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -123,6 +134,11 @@ export function StatusHero({ current={stage} tone={draft ? "ink" : "green"} negative={negative} + // Contract drawdowns are initiated with one click, not submitted + // through the wizard — relabel the stage for them. + labelOverrides={ + booking.contractId ? { 1: "Initiated" } : undefined + } /> )} @@ -132,10 +148,13 @@ export function StatusHero({ function ProgressTracker({ current, tone = "green", + labelOverrides, }: { current: number; tone?: "green" | "ink"; negative?: boolean; + /** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */ + labelOverrides?: Record; }) { const last = PROGRESS_STAGES.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; @@ -227,7 +246,7 @@ function ProgressTracker({ ta="center" c={state === "idle" ? "#9AA8B5" : "#10202F"} > - {stage.label} + {labelOverrides?.[idx] ?? stage.label} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index 03dfa6ac8..ec9b8c509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -1,6 +1,13 @@ import { Box, Button } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + PackagePlus, + PencilLine, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -19,6 +26,7 @@ const ICON_BY_KIND: Record< UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, + BOOK: PackagePlus, }; interface BookingActionButtonProps { @@ -39,6 +47,7 @@ export function BookingActionButton({ size = "sm", }: BookingActionButtonProps) { const [opened, { open, close }] = useDisclosure(false); + const navigate = useNavigate(); // Staff returned the booking for changes — let the customer update the docs // they submitted and resubmit, in place. @@ -49,6 +58,9 @@ export function BookingActionButton({ const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; + // BOOK navigates to the booking form (cargo + day + window check) — the + // same page a one-time booking uses — instead of opening the modal. + const navigateTo = action?.kind === "BOOK" ? action.to : undefined; return ( // Mantine modals portal to , but React events still bubble through @@ -65,7 +77,8 @@ export function BookingActionButton({ leftSection={} onClick={(e) => { e.stopPropagation(); - open(); + if (navigateTo) navigate(navigateTo); + else open(); }} > {label} @@ -77,7 +90,7 @@ export function BookingActionButton({ opened={opened} onClose={close} /> - ) : ( + ) : navigateTo ? null : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index cbb163509..8f93e040c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -48,24 +48,25 @@ function BookingActionModalBody({ const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); return ( + // Sized and styled to match the contract clearance modal + // (ContractClearanceAction) so both flows read as the same surface. - - {action?.title ?? "Booking"} + + {action?.title ?? "Clearance documents"} {reference} } - overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} styles={{ body: { paddingTop: 8 } }} > {flow.isLoading || !flow.clearance ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 5f88349dc..1ebb047a6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -9,7 +9,8 @@ import type { Freight } from "@edr/types"; export type BookingActionKind = | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them - | "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation + | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed + | "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form export interface BookingNextAction { kind: BookingActionKind; @@ -17,6 +18,8 @@ export interface BookingNextAction { label: string; /** Modal title. */ title: string; + /** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */ + to?: string; } const ACTION_BY_STATUS: Record = { @@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record = { }, }; +type ActionBooking = Pick< + Freight.IBooking, + "id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled" +>; + +/** Initiated instance still carrying no cargo/price (clearance-first flow). */ +function isBareInstance(booking: ActionBooking): boolean { + return ( + Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0) + ); +} + /** * Resolve the customer's next clearance/operation action for a booking, or * `null` when there's nothing for them to do at this stage. Pure + cheap so it @@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record = { * "under review" state when nothing is actually queried. */ export function getBookingNextAction( - booking: Pick, + booking: ActionBooking, ): BookingNextAction | null { + if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) { + // Customs (Path B): GL completes the booking — the customer can only view + // the finished clearance in the modal. + if (booking.customsClearingEnabled) { + return { + kind: "SCHEDULE_OPERATION", + label: "View clearance", + title: "Clearance complete", + }; + } + // Non-customs (Path A): straight to the booking form — cargo + shipment + // day + window check, the same page a one-time booking uses. + return { + kind: "BOOK", + label: "Book", + title: "Book your shipment", + to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`, + }; + } return ACTION_BY_STATUS[booking.status as string] ?? null; } @@ -58,9 +92,7 @@ export function getBookingNextAction( * booking that needs documents updated and resubmitting. Used to decide whether * to render {@link BookingActionButton}. */ -export function bookingHasInlineAction( - booking: Pick, -): boolean { +export function bookingHasInlineAction(booking: ActionBooking): boolean { return ( booking.status === "CHANGES_REQUESTED" || getBookingNextAction(booking) !== null 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 b7a1e8c60..ab25284ba 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 @@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean { /** * 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.) + * the one that OPENS soonest from now. Two guards matter here: + * - only openings strictly in the future qualify. A train mid-cycle + * (doc-review/payment) still reports the window that already opened and + * closed; showing that past time as "next" told customers to come back for + * a window that was over. + * - ordered by opening time, not departure date — "next window" is the next + * moment booking opens, which may belong to a later-departing train. + * Returns `null` when nothing upcoming carries a future opening time. */ export function soonestUpcomingWindow( windows: MyBookingWindow[], ): MyBookingWindow | null { + const now = Date.now(); const upcoming = windows - .filter((w) => !w.isOpenNow && w.windowOpensAt) - .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(); - }); + .filter( + (w) => + !w.isOpenNow && + w.windowOpensAt && + new Date(w.windowOpensAt).getTime() > now, + ) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), + ); return upcoming[0] ?? null; }