From e80aac877f7e6b8d7c99e694dfe46d43a1d68478 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 30 Jul 2026 13:11:35 +0000 Subject: [PATCH] export flow and fix intercity issue --- ...3080000000000-BackfillDireDawaMilestone.ts | 57 ++++++ .../bookings/booking-transition.service.ts | 8 + .../modules/bookings/bookings.controller.ts | 20 +- .../contracts/contract-booking.service.ts | 1 + .../dto/create-booking-under-contract.dto.ts | 10 + .../train-scheduling/booking-batch.service.ts | 181 ++++++++++++++++-- .../train-scheduling/intercity.service.ts | 14 +- .../contracts/GlCreateBookingForm.tsx | 59 +++++- .../backoffice/src/services/api.ts | 27 ++- .../src/services/trainScheduling.service.ts | 23 +++ .../bookings/clearance/ClearanceFlow.tsx | 3 +- .../src/pages/contracts/NewShipmentPage.tsx | 74 ++++++- .../contracts/new-shipment-form/schema.ts | 2 + .../portal/src/services/api.ts | 14 +- .../portal/src/services/bookings.service.ts | 14 +- packages/types/src/freight/contracts.ts | 2 + .../ExportTrainPicker}/ExportTrainPicker.tsx | 0 .../src/components/ExportTrainPicker/index.ts | 2 + packages/ui-common/src/index.ts | 2 + 19 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts rename {apps/edr-freight-web/portal/src/pages/bookings/clearance => packages/ui-common/src/components/ExportTrainPicker}/ExportTrainPicker.tsx (100%) create mode 100644 packages/ui-common/src/components/ExportTrainPicker/index.ts diff --git a/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts new file mode 100644 index 000000000..ee44e5971 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop + * in their milestone list. The corridor budget builds its per-leg edges from + * route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP + * booking cannot resolve its own leg and conservatively occupies the WHOLE + * route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP) + * silently degrades to train-wide accounting. + * + * Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY + * route with a stop list that lacks it, shifting later stops down. Matched by + * yard CODE so the repair is portable across environments. Idempotent: routes + * already carrying Dire Dawa are untouched. + */ +export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface { + name = "BackfillDireDawaMilestone3080000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + dire uuid; + r record; + BEGIN + SELECT id INTO dire FROM freight.yards + WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL; + IF dire IS NULL THEN + RETURN; + END IF; + + FOR r IN + SELECT rt.id + FROM freight.routes rt + JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH' + JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY' + WHERE rt.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.yard_id = dire + AND m.deleted_at IS NULL) + LOOP + UPDATE freight.route_milestones + SET sequence_no = sequence_no + 1 + WHERE route_id = r.id AND deleted_at IS NULL AND sequence_no >= 2; + INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no) + VALUES (r.id, dire, 2); + END LOOP; + END $$; + `); + } + + public async down(): Promise { + // Data repair — not reversible. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 9dea12990..149875e6e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1046,6 +1046,13 @@ export class BookingTransitionService { async exportTrainsForBooking( bookingId: string, scheduledDate: string, + overrides?: { + containerTypeIds?: string[]; + containerSizes?: string[]; + cargoTypeId?: string; + cargoTypeCode?: string; + wagons?: number; + }, ): Promise { const booking = await this.bookingsService.findById(bookingId); const date = new Date(scheduledDate); @@ -1064,6 +1071,7 @@ export class BookingTransitionService { return this.bookingBatchService.exportTrainOptionsForDay( scheduledBooking, eatDay(date), + overrides, ); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 875e3b08d..3a17784f3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -765,8 +765,26 @@ export class BookingsController { async exportTrainsForBooking( @Param("id", ParseUUIDPipe) id: string, @Query("date") date: string, + // Bare contract instances carry no cargo yet — the completion form sends + // what the customer is entering so per-type space reflects THEIR cargo. + @Query("containerTypeIds") containerTypeIds?: string, + @Query("containerSizes") containerSizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("cargoTypeCode") cargoTypeCode?: string, + @Query("wagons") wagons?: string, ) { - return this.transitionService.exportTrainsForBooking(id, date); + const parsedWagons = Number(wagons); + return this.transitionService.exportTrainsForBooking(id, date, { + containerTypeIds: containerTypeIds + ? containerTypeIds.split(",").filter(Boolean) + : undefined, + containerSizes: containerSizes + ? containerSizes.split(",").filter(Boolean) + : undefined, + cargoTypeId: cargoTypeId || undefined, + cargoTypeCode: cargoTypeCode || undefined, + wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + }); } @Post(":id/operation/review") diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index d8c26290c..7eb87bf86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -866,6 +866,7 @@ export class ContractBookingService { const completed = await this.bookingTransitionService.requestOperation( booking.id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return { booking: completed, warnings }; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 2ab616c00..8c1bf763d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -175,6 +175,16 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) picked from ' + + 'GET /bookings/:id/export-trains for the shipment day. The reserve path ' + + 'locks onto this train; 409 when it no longer fits. Ignored otherwise.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ enum: SHIPMENT_EQUIPMENT_RETURNS, description: 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 37e2e99e8..9f05192c3 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 @@ -27,6 +27,8 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -459,6 +461,9 @@ export class BookingBatchService implements OnModuleInit { group.destinationYardId, group.day, ); + // Backstop: PAID bookings stranded without a schedule (hold expired before + // the payment landed) get re-placed onto whatever fits today. + await this.rescueStrandedPaidForDay(group.day); for (const scheduleId of scheduleIds) { await this.settleDueReservations(scheduleId); await this.reconcilePaidUnlinked(scheduleId); @@ -519,17 +524,26 @@ export class BookingBatchService implements OnModuleInit { }); 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). + // A paid booking with no train is money taken and nothing boarding. The + // hold was expired before the payment landed (webhook lag beat the + // reconcile, or the stranding predates it) — try to re-place it on a + // fitting same-day train before falling back to a manual-assign scream. 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.`, - ); + const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking); + if (!rescuedScheduleId) { + this.logger.error( + `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + + `its reservation was likely expired before the payment landed and no ` + + `same-day train fits it. Assign it to a schedule manually from the batch board.`, + ); + return; + } + booking.trainScheduleId = rescuedScheduleId; + } else { + return; } - return; } + if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || @@ -644,6 +658,69 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** + * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is + * keyed on train_schedule_id, so a booking whose hold was expired (schedule + * cleared) before its payment landed never re-enters it. Sweep the day's + * PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which + * re-places them on a fitting train. + */ + private async rescueStrandedPaidForDay(day: string): Promise { + const stranded: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.bookings + WHERE deleted_at IS NULL + AND train_schedule_id IS NULL + AND (payment_status = 'PAID' OR status = 'PAID') + AND scheduled_date IS NOT NULL + AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, + [day], + ); + for (const { id } of stranded) { + await this.ensurePaidBookingAllocated(id).catch((err) => + this.logger.error( + `Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`, + ), + ); + } + } + + /** + * Re-place a PAID booking whose hold was expired before the payment landed + * (trainScheduleId already cleared). Picks the earliest same-day train that + * still fits the booking's whole need on ITS OWN leg and pins the booking to + * it. Returns the schedule id, or null when no train fits (manual assign). + */ + private async replaceStrandedPaidBooking( + booking: Booking, + ): Promise { + if (!booking.scheduledDate) return null; + // The booking loaded by ensurePaidBookingAllocated carries no cargo + // relations; needFor/fittingTrainsForDay derive the wagon need from them. + const full = await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (!full) return null; + const day = eatDay(new Date(booking.scheduledDate)); + const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(full, wagonDims); + const fitting = await this.fittingTrainsForDay(full, day, direction); + const target = fitting.find((t) => t.freeWagons >= need.wagons); + if (!target) return null; + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: target.scheduleId }); + this.logger.warn( + `[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` + + `onto schedule ${target.scheduleId} — its hold expired before the payment landed`, + ); + return target.scheduleId; + } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ async getOpenOfferSummary(bookingId: string): Promise<{ offeredWagons: number; @@ -911,7 +988,50 @@ export class BookingBatchService implements OnModuleInit { async exportTrainOptionsForDay( booking: Booking, day: string, + overrides?: { + /** Cargo the customer is entering on a form (bare contract instance — + * nothing persisted yet): container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; + }, ): Promise { + const sizeFts = (overrides?.containerSizes ?? []) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); + if (overrides?.containerTypeIds?.length || sizeFts.length) { + const types = await this.dataSource.getRepository(ContainerType).find({ + where: overrides?.containerTypeIds?.length + ? { id: In(overrides.containerTypeIds) } + : { sizeFt: In(sizeFts) }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "CONTAINER", + bookingContainers: types.map((ct) => ({ containerType: ct })), + } as Booking; + } else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) { + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: overrides.cargoTypeId + ? { id: overrides.cargoTypeId } + : { code: overrides.cargoTypeCode }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "BULK", + cargoType: cargoType ?? undefined, + } as Booking; + } + if (overrides?.wagons && overrides.wagons > 0) { + booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; + } const corridor = await this.trainSchedulesRepository.findAll({ where: [ { status: TrainScheduleStatusEnum.Draft }, @@ -2200,14 +2320,16 @@ export class BookingBatchService implements OnModuleInit { * partial (split-on-payment). Consolidated pairs never split (both-or-neither * shared wagon) and government bookings never split (they preempt). * - * IMPORT is always eligible. EXPORT is eligible only when export split is - * enabled: export historically rides one train whole, so splitting it changes - * the FCFS money path — each split part still rides ONE train whole, and the - * leftover becomes its own booking on the next train. + * IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is + * eligible only when export split is enabled: export historically rides one + * train whole, so splitting it changes the FCFS money path — each split part + * still rides ONE train whole, and the leftover becomes its own booking on + * the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { const directionOk = booking.tradeDirection === "IMPORT" || + booking.tradeDirection === "DOMESTIC" || (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && @@ -2818,6 +2940,41 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } + /** + * Intercity booking that does not fit its leg whole: offer the largest part + * that does (split-on-payment, customer notified with a pay window), sized + * against the leg's remaining room AND the train's physical wagon stock. + * Returns true when an offer was opened. The caller's budget is mutated so + * later bookings in the same accept pass see the offer's consumption. + */ + async offerIntercityPartial( + booking: Booking, + scheduleId: string, + budget: CorridorBudget, + ): Promise { + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(booking, wagonDims); + const allowed = await this.loadAllowedWagonTypeIds(); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + const stock = await this.stockLedgerFor(schedule, budget); + const cand = { id: scheduleId, budget, armed: false, stock }; + const offered = await this.maybeOfferPartial( + booking, + false, + [cand], + need, + wagonTypeIds, + ); + if (offered && cand.armed) { + this.armSettle(scheduleId); + this.notifyBoardChanged(scheduleId, 'intercity_partial_offered'); + } + return offered; + } + // ---- mutations ------------------------------------------------------------ /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 293fc8801..4d5ae2330 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -217,10 +217,20 @@ export class IntercityService { // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { + // Offer the part that DOES fit the leg (split-on-payment): customer is + // notified with a pay window for the fitting wagons; the remainder can + // be re-booked on a later train. Budget is consumed by the offer so the + // next booking in this pass sees the reduced room. + const offered = await this.bookingBatchService.offerIntercityPartial( + booking, + scheduleId, + budget, + ); rejected.push({ bookingId, - reason: - 'Does not fit the remaining wagon/weight/length capacity for this train', + reason: offered + ? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer' + : 'Does not fit the remaining wagon/weight/length capacity for this train', }); continue; } 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 d91435495..cc9ced8a2 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -48,7 +48,7 @@ import { X, } from "lucide-react"; import type { Freight } from "@edr/types"; -import { OperationDatePicker } from "@edr/ui-common"; +import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; @@ -268,6 +268,8 @@ export default function GlCreateBookingForm() { }, [bookingWindows]); const [scheduledDate, setScheduledDate] = useState(""); + // EXPORT rail completion: the specific train GL picks for the shipment day. + const [trainScheduleId, setTrainScheduleId] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); // The customer states the billing currency on their shipment request — GL @@ -578,6 +580,45 @@ export default function GlCreateBookingForm() { enabled: cargoQuery !== null && !isIntercity, }); + // EXPORT completes pick the TRAIN, not just the day (portal parity). Only + // when completing an initiated instance — a fresh GL create goes through + // clearance and picks its train there. + const isExportPick = + contract?.tradeDirection === "EXPORT" && Boolean(completeBookingId); + const wagonsEstimate = useMemo(() => { + if (!isContainer) return undefined; + const ft20 = containerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const ft40 = containerLines + .filter((l) => parseInt(l.containerSize, 10) === 40) + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const wagons = Math.ceil(ft20 / 2) + ft40; + return wagons > 0 ? wagons : undefined; + }, [isContainer, containerLines]); + const exportTrainsQuery = useQuery({ + ...api.trainScheduling.exportTrains.queryOptions({ + input: { + bookingId: completeBookingId ?? "", + date: scheduledDate, + cargo: { + containerSizes: isContainer + ? containerLines + .filter((l) => Number(l.quantity || 0) >= 1) + .map((l) => l.containerSize) + : undefined, + cargoTypeCode: !isContainer + ? (contract?.pricingBreakdown?.lineItems?.find( + (li) => li.cargoTypeCode, + )?.cargoTypeCode ?? undefined) + : undefined, + wagons: wagonsEstimate, + }, + }, + }), + enabled: isExportPick && Boolean(scheduledDate), + }); + /** * Line handling totals are a roll-up of the per-container switches — the * count is however many containers ticked each service. Recomputed on every @@ -846,6 +887,8 @@ export default function GlCreateBookingForm() { ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), + // EXPORT rail: lock the booking onto the picked train. + ...(trainScheduleId ? { trainScheduleId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), // Equipment return: WITH_RETURN contracts derive it server-side from the // per-line return quantities; only legacy contracts (no value chosen at @@ -1667,7 +1710,11 @@ export default function GlCreateBookingForm() { availableDays={availableDays ?? []} isLoading={daysLoading} value={scheduledDate} - onChange={setScheduledDate} + onChange={(d) => { + setScheduledDate(d); + // A new day invalidates the old train pick. + setTrainScheduleId(""); + }} /> {showErrors && dateError && ( @@ -1675,6 +1722,14 @@ export default function GlCreateBookingForm() { {dateError} )} + {isExportPick && scheduledDate ? ( + + ) : null} )} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index ef84826aa..a0fa8a35e 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1,4 +1,4 @@ -import type { PaginatedResponse } from "@edr/types"; +import type { Freight, PaginatedResponse } from "@edr/types"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; @@ -438,6 +438,31 @@ export const api = { ], ), + exportTrains: endpoint< + { + bookingId: string; + date: string; + cargo?: { + containerSizes?: string[]; + cargoTypeCode?: string; + wagons?: number; + }; + }, + Freight.ExportTrainOption[] + >( + "train-scheduling", + "export-trains", + ({ bookingId, date, cargo }) => + trainSchedulingService.getExportTrains(bookingId, date, cargo), + ({ bookingId, date, cargo }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "export-trains", + bookingId, + date, + JSON.stringify(cargo ?? {}), + ], + ), + trainTrack: endpoint<{ id: string }, TrainTrackResponse>( "train-scheduling", "track", diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 79d868ac0..c176061e1 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -189,6 +189,29 @@ export const trainSchedulingService = { // Cargo-aware day pool (matching wagons + open train capacity). `containers` // is serialized as a JSON string param (the server parses it). + // Export train picker for a booking's shipment day; cargo params cover bare + // instances whose cargo only exists on the form so far. + getExportTrains: async ( + bookingId: string, + date: string, + cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number }, + ): Promise => { + const response = await client.get( + `/bookings/${bookingId}/export-trains`, + { + params: { + date, + ...(cargo?.containerSizes?.length + ? { containerSizes: cargo.containerSizes.join(",") } + : {}), + ...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}), + ...(cargo?.wagons ? { wagons: cargo.wagons } : {}), + }, + }, + ); + return unwrap(response.data); + }, + getAvailableDaysForCargo: async ( query: Freight.AvailableDaysForCargoQuery, ): Promise => { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index 681cbd7b2..a1d2cafd8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import type { Freight } from "@edr/types"; -import { isViewable } from "@edr/ui-common"; +import { ExportTrainPicker, isViewable } from "@edr/ui-common"; import { IconSquare } from "../BookingDetailPage/components/Documents"; import { @@ -27,7 +27,6 @@ import { useFileViewer } from "@/hooks/useFileViewer"; import { bookingDocNoun } from "./bookingNextAction"; import { OperationDatePicker } from "./OperationDatePicker"; import { DayAvailabilityHint } from "./DayAvailabilityHint"; -import { ExportTrainPicker } from "./ExportTrainPicker"; import type { ClearanceFlowController } from "./useClearanceFlow"; const BORDER = "#E6ECF2"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index d315a104c..99eef8bdf 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -49,7 +49,7 @@ import { } from "lucide-react"; import type { Freight } from "@edr/types"; -import { OperationDatePicker } from "@edr/ui-common"; +import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { contractsService, @@ -342,6 +342,10 @@ function NewShipmentBookingForm({ ...(values.scheduledDate ? { scheduledDate: new Date(values.scheduledDate).toISOString() } : {}), + // EXPORT rail: lock the booking onto the train the customer picked. + ...(values.trainScheduleId + ? { trainScheduleId: values.trainScheduleId } + : {}), ...(legacyReturnToggle ? { equipmentReturn: values.withReturn @@ -505,7 +509,12 @@ function NewShipmentBookingForm({ return quantities in the cargo step; WITHOUT_RETURN locked it off. */} {contract.freightType === "CONTAINER" && !contract.equipmentReturn && } - + @@ -934,10 +943,13 @@ function ScheduleStep({ form, contract, routes, + completeBookingId, }: { form: ShipmentForm; contract: Freight.IContract; routes: Freight.IContractRoute[]; + /** Set when completing an initiated instance — enables the train picker. */ + completeBookingId: string | null; }) { const contractRouteId = form.watch("contractRouteId"); const route = routes.find((r) => r.id === contractRouteId) ?? routes[0]; @@ -996,6 +1008,50 @@ function ScheduleStep({ enabled: cargoQuery !== null && !isIntercity, }); + // Export completion picks the TRAIN, not just the day (mirrors the + // clearance-flow picker). Only when an initiated instance exists — a plain + // drawdown create goes through clearance and picks its train there. + const scheduledDate = form.watch("scheduledDate"); + const selectedTrainId = form.watch("trainScheduleId"); + const isExportPick = + contract.tradeDirection === "EXPORT" && Boolean(completeBookingId); + const wagonsEstimate = useMemo(() => { + if (contract.freightType !== "CONTAINER") return undefined; + const lines = containerLines ?? []; + const ft20 = lines + .filter((l) => l.containerSize === "20ft") + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const ft40 = lines + .filter((l) => l.containerSize === "40ft") + .reduce((s, l) => s + Number(l.quantity || 0), 0); + const wagons = Math.ceil(ft20 / 2) + ft40; + return wagons > 0 ? wagons : undefined; + }, [contract.freightType, containerLines]); + const exportTrainsQuery = useQuery({ + ...api.bookings.getExportTrains.queryOptions({ + input: { + bookingId: completeBookingId ?? "", + date: scheduledDate ?? "", + cargo: { + containerSizes: + contract.freightType === "CONTAINER" + ? (containerLines ?? []) + .filter((l) => Number(l.quantity || 0) >= 1) + .map((l) => l.containerSize) + : undefined, + cargoTypeCode: + contract.freightType === "BULK" + ? (contract.pricingBreakdown?.lineItems?.find( + (li) => li.cargoTypeCode, + )?.cargoTypeCode ?? undefined) + : undefined, + wagons: wagonsEstimate, + }, + }, + }), + enabled: isExportPick && Boolean(scheduledDate), + }); + if (isIntercity) { return ( @@ -1067,7 +1123,11 @@ function ScheduleStep({ availableDays={availableDays ?? []} isLoading={isLoading} value={field.value ?? ""} - onChange={(d) => field.onChange(d)} + onChange={(d) => { + field.onChange(d); + // A new day invalidates the old train pick. + form.setValue("trainScheduleId", ""); + }} /> {fieldState.error?.message && ( @@ -1075,6 +1135,14 @@ function ScheduleStep({ {fieldState.error.message} )} + {isExportPick && scheduledDate ? ( + form.setValue("trainScheduleId", id)} + /> + ) : null} )} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index b227ccaa3..c33a6cc43 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -73,6 +73,8 @@ const containerLineSchema = z.object({ const shipmentFormBase = z.object({ contractRouteId: z.string().default(""), scheduledDate: z.string().default(""), + // EXPORT rail: the specific train picked for the shipment day (schedule id). + trainScheduleId: z.string().default(""), // The contract quotes in USD; the customer picks the billing currency for // THIS shipment. Intercity is forced to ETB (server-enforced too). paymentCurrency: z.enum(["USD", "ETB"]).default("USD"), diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a675198c5..7546fedbc 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -469,10 +469,18 @@ export const api = { ), getExportTrains: endpoint< - { bookingId: string; date: string }, + { + bookingId: string; + date: string; + cargo?: { + containerSizes?: string[]; + cargoTypeCode?: string; + wagons?: number; + }; + }, Freight.ExportTrainOption[] - >("train-scheduling", "exportTrains", ({ bookingId, date }) => - bookingsService.getExportTrains(bookingId, date), + >("train-scheduling", "exportTrains", ({ bookingId, date, cargo }) => + bookingsService.getExportTrains(bookingId, date, cargo), ), getMyBookingWindows: endpoint( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 1aa819009..79d61a1a9 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -510,13 +510,25 @@ export const bookingsService = { }, // Export train picker: the day's export trains with per-wagon-type free space. + // The cargo params cover bare contract instances (nothing persisted yet) — + // sizes/code/wagons come from what the customer is entering on the form. getExportTrains: async ( bookingId: string, date: string, + cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number }, ): Promise => { const { data } = await client.get( `/api/bookings/${bookingId}/export-trains`, - { params: { date } }, + { + params: { + date, + ...(cargo?.containerSizes?.length + ? { containerSizes: cargo.containerSizes.join(",") } + : {}), + ...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}), + ...(cargo?.wagons ? { wagons: cargo.wagons } : {}), + }, + }, ); return data.data as Freight.ExportTrainOption[]; }, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 39dc2aca4..4581a2862 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -999,6 +999,8 @@ export interface CreateBookingUnderContractDto { paymentCurrency?: string; /** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */ scheduledDate?: string; + /** EXPORT rail only: the train (schedule id) picked from GET /bookings/:id/export-trains. */ + trainScheduleId?: string; /** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */ equipmentReturn?: string; containers?: CreateBookingContainerLineDto[]; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ExportTrainPicker.tsx b/packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx similarity index 100% rename from apps/edr-freight-web/portal/src/pages/bookings/clearance/ExportTrainPicker.tsx rename to packages/ui-common/src/components/ExportTrainPicker/ExportTrainPicker.tsx diff --git a/packages/ui-common/src/components/ExportTrainPicker/index.ts b/packages/ui-common/src/components/ExportTrainPicker/index.ts new file mode 100644 index 000000000..5d408244f --- /dev/null +++ b/packages/ui-common/src/components/ExportTrainPicker/index.ts @@ -0,0 +1,2 @@ +export { ExportTrainPicker } from "./ExportTrainPicker"; +export type { ExportTrainPickerProps } from "./ExportTrainPicker"; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 8fb36be26..60e5c39f8 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -24,6 +24,8 @@ export { useFileViewer } from "./hooks/useFileViewer"; export { OperationDatePicker } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker"; +export { ExportTrainPicker } from "./components/ExportTrainPicker"; +export type { ExportTrainPickerProps } from "./components/ExportTrainPicker"; export { CountdownTimer } from "./components/CountdownTimer"; export type { CountdownTimerProps } from "./components/CountdownTimer";