From 29371b0b394110e6c9c76b8e89f86f200ad86558 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 25 Aug 2026 22:01:03 +0000 Subject: [PATCH] feat: implement MANUAL_ONLY status for bookings and update related logic --- .../booking-batch.service.spec.ts | 39 +++++ .../train-scheduling/booking-batch.service.ts | 7 + .../services/train-scheduling.service.spec.ts | 83 +++++++++- .../services/train-scheduling.service.ts | 7 +- .../trainScheduling/ScheduleStatusBadge.tsx | 2 + .../ContractTemplatesPage.tsx | 144 ++++++++++++++---- .../backoffice/src/types/trainScheduling.ts | 4 +- packages/types/src/freight/index.ts | 7 + 8 files changed, 257 insertions(+), 36 deletions(-) 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 5c76b7803..9051bb816 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 @@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => { ).not.toHaveBeenCalled(); }); + it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + trainScheduleId: null, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + + it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + schedulingStatus: 'MANUAL_ONLY', + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + }); + + it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'MANUAL_ONLY' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); 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 9e16bba60..d787f737d 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 @@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit { // train 30s after being cancelled. Never resurrect a dead booking. if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) return; + // Staff removed this booking from a train (dispatch left-behind / manual + // unassign) — every auto-allocation rescue below must leave it alone, or + // the next document review / sweep silently retakes the space it was + // pulled from. Only a manual staff assignment may re-place it. + if (booking.schedulingStatus === "MANUAL_ONLY") return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the @@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit { for (const booking of unlinked) { // Held on purpose (paid, no wagon free) — the cron must not undo it. if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + // Removed from a train by staff — manual re-assignment only. + if (booking.schedulingStatus === "MANUAL_ONLY") continue; if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 15cf4f592..57bbb2c58 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, - {} as never, // trainCompositionRemovalLogRepository + { create: jest.fn() } as never, // trainCompositionRemovalLogRepository { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), @@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => { { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService - { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); @@ -1640,6 +1640,85 @@ describe('TrainSchedulingService', () => { }); }); + describe('unassignBooking — MANUAL_ONLY status', () => { + const scheduleId = 'sched-rm-1'; + const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + status: 'PAID', + wagonsRequired: 5, + }); + + const graph = { + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-rm', + trainSet: { + id: 'ts-rm', + locomotive, + trainId: null, + wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }], + }, + scheduleBookings: [{ bookingId: 'bk-rm' }], + }; + + const txManager = { + getRepository: jest.fn(() => ({ + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockResolvedValue(undefined), + create: jest.fn((x: unknown) => x), + })), + }; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph); + bookingsRepository.findById = jest.fn().mockResolvedValue(removed); + bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => fn(txManager), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: scheduleId } as never); + }); + + it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith( + 'bk-rm', + expect.objectContaining({ + schedulingStatus: 'MANUAL_ONLY', + trainScheduleId: null, + wagonsRequired: null, + }), + expect.anything(), + ); + expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith( + scheduleId, + 'bk-rm', + expect.anything(), + ); + expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith( + ['alloc-rm-1'], + expect.anything(), + ); + }); + + it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => { + await service.unassignBooking(scheduleId, 'bk-rm', 'user-1'); + + const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]); + expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false); + }); + }); + describe('updateCheckpoint — leg time correction', () => { const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); const schedule = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 2340f7b10..5d3b05bbb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2405,7 +2405,12 @@ export class TrainSchedulingService { ); const booking = await this.bookingsRepository.findById(bookingId); - const schedulingStatus = this.resolvePostUnassignStatus(booking); + // Removed from a train by staff → MANUAL_ONLY: the paid booking must not + // be auto re-placed by any allocation sweep (it would retake the space it + // was just pulled from). Staff re-assign it manually; assign resets the + // status to SCHEDULED. Schedule *cancellation* keeps the old behaviour + // (resolvePostUnassignStatus) — there the train died, not the booking. + const schedulingStatus = SchedulingStatus.ManualOnly; // Clear the schedule pointer too: unassign fully detaches the booking from // this train. Leaving trainScheduleId set glued the booking to a schedule // that may then be dispatched/cancelled/deleted, orphaning it — the diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx index 565187ac2..186588dd1 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleStatusBadge.tsx @@ -35,6 +35,8 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) { ELIGIBLE: "blue", SCHEDULED: "indigo", DISPATCHED: "edr-green", + WAITING_FOR_WAGON: "yellow", + MANUAL_ONLY: "orange", }; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index e326fa788..d8e360114 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -8,6 +8,7 @@ import { Card, Group, Modal, + Radio, SegmentedControl, Select, SimpleGrid, @@ -23,10 +24,13 @@ import { Container, Eye, FileText, + Flag, + Globe, Lock, Pencil, Plus, Trash2, + type LucideIcon, } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; @@ -109,6 +113,39 @@ function directionOf(template: ContractTemplate): string { : template.code.split("_")[0]; } +/** The three customs options offered in the create-template modal. */ +const CUSTOMS_OPTIONS: Array<{ + value: string; + label: string; + description: string; + color: string; + icon: LucideIcon; +}> = [ + { + value: "true", + label: "With customs clearing", + description: + "The Service Provider clears customs in both Djibouti and Ethiopia on the client's behalf.", + color: "teal", + icon: Globe, + }, + { + value: "ethiopian", + label: "Ethiopian customs only", + description: + "The Service Provider clears the Ethiopian side only — Djibouti clearing stays with the client. Used for service types marked “Ethiopian customs only”.", + color: "indigo", + icon: Flag, + }, + { + value: "false", + label: "Without customs clearing", + description: "Transport only — the client handles its own declarations.", + color: "gray", + icon: FileText, + }, +]; + function formatUpdated(value: string): string { return new Date(value).toLocaleDateString("en-GB", { day: "numeric", @@ -281,8 +318,25 @@ function CreateTemplateModal({ }; return ( - - + + + New bulk contract template + + + One template per trade direction, customs option and commodity + + + } + > +
Trade direction @@ -299,36 +353,6 @@ function CreateTemplateModal({ />
- {intercity ? ( - - Intercity contracts are domestic and cross no border, so they have - no customs clearing variant — one template per cargo type. - - ) : ( -
- - Customs clearing - - - {withCustoms === "ethiopian" && ( - - Used for service types marked “Ethiopian customs only”: the - Service Provider clears the Ethiopian side, Djibouti clearing - stays with the client. - - )} -
- )} -