From 1433796db0d7ece9266d5d948f8bde3162f7ca38 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 19:56:21 +0000 Subject: [PATCH] add loading status management for bookings --- .../train-scheduling.controller.ts | 13 +++ .../train-scheduling.service.ts | 99 ++++++++++++++++++- .../ScheduleWorkspacePanel.tsx | 92 ++++++++++++++++- .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 2 - .../backoffice/src/services/api.ts | 12 +++ .../src/services/trainScheduling.service.ts | 11 +++ .../backoffice/src/types/trainScheduling.ts | 2 + 8 files changed, 222 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 770906320..54e4e51d6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -372,6 +372,19 @@ export class TrainSchedulingController { return this.trainSchedulingService.updateImportLoadingStatus(id, dto); } + @Patch("schedules/:id/loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", + }) + setBookingLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.setBookingLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) 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 6f5fb5449..be1b09070 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 @@ -787,11 +787,49 @@ export class TrainSchedulingService { const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); + + // Callers that add bookings without hand-picking container slots (the + // workspace "Add from pool" button, re-adding a removed booking) send no + // containerPlacements. Auto-fill them the same way the batch engine does: + // preview the wagon plan first, then lay containers into the plan's slots. + // Without this the placement validator rejects container bookings outright + // ("Container placements are required for container bookings"). + let containerPlacements = dto.containerPlacements; + if (!containerPlacements?.length) { + const preview = await this.validateBookingsForScheduling( + previewDto, + freightType ?? null, + dto.forceAssign, + [], + false, + limits, + scheduleId, + ); + const containerBookings = preview.bookings.filter( + (b) => b.freightType === 'CONTAINER', + ); + if (containerBookings.length) { + const units = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(preview.wagonPlan); + const generated = autoFillPlacements(units, slots); + const missing = findMissingContainerNumberIssues(units, generated); + if (missing.length) { + throw new BadRequestException({ + message: `Booking validation failed: ${missing + .map((m) => m.issue) + .join('; ')}`, + violations: missing.map((m) => m.issue), + }); + } + containerPlacements = generated; + } + } + const validation = await this.validateBookingsForScheduling( previewDto, freightType ?? null, dto.forceAssign, - dto.containerPlacements, + containerPlacements, true, limits, scheduleId, @@ -887,7 +925,7 @@ export class TrainSchedulingService { savedWagons, wagonPlan, bookings, - dto.containerPlacements ?? [], + containerPlacements ?? [], ); for (const booking of bookings) { @@ -1151,6 +1189,50 @@ export class TrainSchedulingService { return this.getImportLoadingBookings(scheduleId); } + /** + * Flip loaded/unloaded on the schedule↔booking link from the workspace, for any + * direction (import/export/domestic). Distinct from unassign: the booking stays + * on its wagon; this only records whether cargo is physically loaded. Allowed + * only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival + * warehouse automation owns unload, so staff can no longer hand-edit the flag. + */ + async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Loading status can only be changed before dispatch (schedule is ${schedule.status})`, + ); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + + // Only bookings that are on this train AND pinned to a wagon can be loaded — + // no direction/payment filter, staff load whatever is physically on the set. + const invalid = dto.bookingIds.filter( + (id) => !scheduledIds.has(id) || !allocatedIds.has(id), + ); + if (invalid.length) { + throw new BadRequestException( + `Not allocated to a wagon on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getTrainScheduleById(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -3843,9 +3925,11 @@ export class TrainSchedulingService { private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { - const allocationIds = (schedule.trainSet?.wagons ?? []) - .flatMap((w) => w.allocations ?? []) - .map((a) => a.id); + const allocations = (schedule.trainSet?.wagons ?? []).flatMap( + (w) => w.allocations ?? [], + ); + const allocationIds = allocations.map((a) => a.id); + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); const windowCfg = await this.getWindowConfig(); @@ -4004,6 +4088,11 @@ export class TrainSchedulingService { status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, + // Loaded/unloaded is tracked on the schedule↔booking link, not the + // booking itself — staff flip it per booking in the workspace before + // dispatch. Defaults UNLOADED for links written before the column. + loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, + wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], }; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 2a7689582..c93e4863c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -23,6 +23,7 @@ import { CheckCircle2, Inbox, PackageCheck, + PackageX, Repeat, Train, Weight, @@ -152,6 +153,9 @@ export function ScheduleWorkspacePanel({ // ── Mutations (reuse the existing endpoints) ─────────────────────────────── const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const setLoading = useMutation( + api.trainScheduling.setLoadingStatus.mutationOptions(), + ); const moveSchedule = useMutation( api.trainScheduling.moveBookingSchedule.mutationOptions(), ); @@ -237,6 +241,31 @@ export function ScheduleWorkspacePanel({ ); }; + const toggleLoaded = ( + bookingId: string, + ref: string, + next: "LOADED" | "UNLOADED", + ) => { + setLoading + .mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next }) + .then(() => { + toast({ + title: + next === "LOADED" + ? `${ref} marked loaded` + : `${ref} marked unloaded`, + }); + onChanged(); + }) + .catch((error) => + toast({ + title: "Could not update loading status", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ); + }; + const doMove = () => { if (!moveBookingId || !moveTarget) return; moveSchedule @@ -268,7 +297,7 @@ export function ScheduleWorkspacePanel({
Allocation workspace - Manually add ready-to-pay bookings, remove, or reassign them + Manually add paid, unassigned bookings, remove, or reassign them
@@ -351,13 +380,13 @@ export function ScheduleWorkspacePanel({ {/* Pool */} {pool.map((b) => ( + {b.wagonAssigned ? ( + + + + ) : null}