diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 314d31a7f..0354dbc84 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () expect(dto.requestedWagons).toBeUndefined(); }); }); + +/** + * The cancellation fee is paid BEFORE the credit is redeemed. + * + * An at-loading cut applies immediately and opens the credit while its fee + * invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was + * settled. Without the gate the customer rebooks the same wagons and the + * cancellation fee is simply never collected. EDR-fault cuts carry no fee and + * must stay freely rebookable — partial or whole, container or bulk. + */ +describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => { + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'ETB', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (row: Record) => { + const svc = Object.create(BookingWagonCancellationService.prototype) as Record< + string, + unknown + > & { rebook(id: string, dto: unknown): Promise }; + svc.repo = { findById: async () => row }; + svc.bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async () => null, + }; + return svc; + }; + + /** Bulk credit — no bySize, so nothing depends on container snapshots. */ + const bulkRow = (over: Record) => ({ + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 5000, + wagonsCancelled: 2, + cancelledQuantities: { bulkTons: 100 }, + feeCurrency: 'ETB', + ...over, + }); + + it('blocks a rebook while a customer-fault fee is unpaid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i, + ); + }); + + it('blocks a WHOLE-booking customer-fault cancel just the same', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /4 wagon\(s\) before rebooking/i, + ); + }); + + it('lets the rebook through once the fee is paid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }), + ); + // Past the gate it fails later (no contract/create wiring in this harness) — + // what matters is that it is no longer the fee that stops it. + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('never charges an EDR-fault cut', async () => { + const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('leaves legacy rows without a fee untouched', async () => { + const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index a84f0907f..baed65bfc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1003,6 +1003,18 @@ export class BookingWagonCancellationService { 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', ); } + // Customer-fault fee settles BEFORE the credit is redeemed. An at-loading + // cut applies immediately and opens the credit while its invoice stays + // open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without + // this the customer rebooks the wagons and never pays the cancellation + // fee the notice already promised. EDR fault carries no fee and is + // unaffected; onFeePaid stamps feePaidAt and the gate opens by itself. + if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) { + throw new BadRequestException( + `Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` + + `${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`, + ); + } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts new file mode 100644 index 000000000..c9ee445ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts @@ -0,0 +1,268 @@ +import { BadRequestException } from "@nestjs/common"; + +import { TrainSchedulingService } from "./services/train-scheduling.service"; + +/** + * Mid-corridor leave-behind. Logging a pass at station N means the train has + * LEFT station N-1, so cargo that boarded back there has had its last chance + * to load: anything the operator did not tick rides no further and is + * unassigned back to the booking pool. + * + * Dispatch already does this for the origin yard; these cover the log-pass + * twin, plus the structured payload the UI needs to offer the + * EDR-fault / customer-fault cut on a part-loaded booking. + */ +describe("recordCheckpoint — mid-corridor leave-behind", () => { + const STATIONS = [ + { sequenceNo: 0, yardId: "yard-a", label: "Yard A" }, + { sequenceNo: 1, yardId: "yard-b", label: "Yard B" }, + { sequenceNo: 2, yardId: "yard-c", label: "Yard C" }, + ]; + + /** + * Exercises the leave-behind block in isolation — the surrounding + * recordCheckpoint does heavy graph/transaction work irrelevant here. + */ + const runLeaveBehind = async ( + dto: { sequenceNo: number; loadedBookingIds?: string[] }, + candidatesByYard: Record, + ) => { + const unassigned: Array<{ scheduleId: string; bookingId: string }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise; + unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + ): Promise; + }; + svc.unloadedBoarderIdsAtYard = async ( + _scheduleId: string, + yardId: string, + ) => candidatesByYard[yardId] ?? []; + svc.unassignBooking = async (scheduleId: string, bookingId: string) => { + unassigned.push({ scheduleId, bookingId }); + }; + + // Mirrors the block inside recordCheckpoint. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = STATIONS.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await svc.unloadedBoarderIdsAtYard( + "sched-1", + departedYardId, + ); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await svc.unassignBooking("sched-1", bookingId, undefined); + } + } + } + return unassigned.map((u) => u.bookingId); + }; + + it("drops the unticked boarders of the yard the train just left", async () => { + // b4 and b5 boarded at Yard B; only b5 was loaded. Logging Yard C means + // the train has left B, so b4 is stranded and comes off the train. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual(["b4"]); + }); + + it("scopes the drop to the DEPARTED yard, never the one being logged", async () => { + // Cargo boarding at Yard C is not due until the train is there — logging + // the pass at C must not shed it. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: [] }, + { "yard-b": [], "yard-c": ["b6", "b7"] }, + ); + expect(dropped).toEqual([]); + }); + + it("leaves nobody behind when the client omits the list", async () => { + // Older clients send no list — the historic behavior is that everyone rides. + const dropped = await runLeaveBehind( + { sequenceNo: 2 }, + { "yard-b": ["b4"] }, + ); + expect(dropped).toEqual([]); + }); + + it("does not shed at the origin — that is dispatch's decision", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 0, loadedBookingIds: [] }, + { "yard-a": ["b1", "b3"] }, + ); + expect(dropped).toEqual([]); + }); + + it("keeps every ticked booking on the train", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b4", "b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual([]); + }); +}); + +describe("assertNoPartiallyLoadedBookings — structured payload", () => { + const makeService = ( + rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }>, + ) => { + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { + query: (sql: string, params: unknown[]) => Promise; + }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + }; + svc.dataSource = { query: async () => rows }; + return svc; + }; + const schedule = { + id: "sched-1", + trainSetId: "set-1", + originStationId: "yard-a", + }; + + it("carries the never-loaded allocation ids the fault-cut modal needs", async () => { + const svc = makeService([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loaded: "1", + total: "4", + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + + const err = await svc + .assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "record this checkpoint", + yardLabel: "Yard B", + }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(BadRequestException); + const body = (err as BadRequestException).getResponse() as { + code: string; + message: string; + partiallyLoaded: { + yardLabel: string | null; + bookings: Array<{ + bookingId: string; + loadedWagons: number; + totalWagons: number; + unloadedAllocationIds: string[]; + }>; + }; + }; + + expect(body.code).toBe("PARTIALLY_LOADED_BOOKINGS"); + expect(body.partiallyLoaded.yardLabel).toBe("Yard B"); + expect(body.partiallyLoaded.bookings).toEqual([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loadedWagons: 1, + totalWagons: 4, + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + // The prose message survives for logs and older clients. + expect(body.message).toContain("BK-2026-000853 (1/4 wagons loaded)"); + }); + + it("stays silent when nothing at the yard is half-loaded", async () => { + const svc = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "dispatch", + }), + ).resolves.toBeUndefined(); + }); +}); + +/** + * Dispatch's origin auto-load. This UPDATE is the reason an unticked booking + * could still end up marked loaded: it stamps every PAID origin boarder, so + * without the confirmed-list guard a booking left attached (or one the + * unassign predicate cannot shed) rides as if its cargo were aboard. + */ +describe('dispatchSchedule — origin auto-load respects the confirmed list', () => { + /** Mirrors the `($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))` guard. */ + const wouldAutoLoad = (bookingId: string, confirmed: string[] | undefined) => + confirmed === undefined || confirmed.includes(bookingId); + + it('stamps only the ticked bookings', () => { + expect(wouldAutoLoad('b2', ['b2'])).toBe(true); + expect(wouldAutoLoad('b1', ['b2'])).toBe(false); + }); + + it('stamps nobody when the operator unticks everyone', () => { + expect(wouldAutoLoad('b1', [])).toBe(false); + }); + + it('keeps the historic auto-load for clients that send no list', () => { + expect(wouldAutoLoad('b1', undefined)).toBe(true); + expect(wouldAutoLoad('b2', undefined)).toBe(true); + }); +}); + +/** + * Empty wagons must travel with their train. + * + * The checkpoint position fix moves wagons by `current_train_schedule_id`, but + * dispatch used to bind only the PINNED slots (the ones carrying cargo). A + * built train rolls with its whole consist, so every empty wagon coupled to it + * was left unbound — and stayed recorded at the origin yard while the train it + * is hooked to travelled the corridor. + */ +describe('dispatchSchedule — the whole consist travels, not just loaded slots', () => { + /** Mirrors dispatch's binding set: pinned slots ∪ built-train consist. */ + const boundAtDispatch = ( + pinnedSlotWagonIds: Array, + builtTrainWagonIds: string[], + ) => [ + ...new Set([ + ...pinnedSlotWagonIds.filter((id): id is string => Boolean(id)), + ...builtTrainWagonIds, + ]), + ]; + + it('binds the empty wagons coupled to the built train', () => { + // The real shape of the reported schedule: 3 slots carry cargo, 45 empties + // ride along. All 48 must move when a checkpoint is logged. + const pinned = ['w1', 'w2', 'w3']; + const consist = ['w1', 'w2', 'w3', 'e1', 'e2', 'e3']; + const bound = boundAtDispatch(pinned, consist); + expect(bound).toEqual(['w1', 'w2', 'w3', 'e1', 'e2', 'e3']); + expect(bound).toContain('e1'); + }); + + it('never double-binds a wagon that is both pinned and on the train', () => { + const bound = boundAtDispatch(['w1', 'w1'], ['w1']); + expect(bound).toEqual(['w1']); + }); + + it('still binds pinned slots when there is no built train', () => { + // A set-only schedule (no Train row) has no consist to add. + expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 06c363bc4..d96a1db4a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { TrainCheckpointKind } from '@edr/types'; +import { ApiProperty } from "@nestjs/swagger"; +import { TrainCheckpointKind } from "@edr/types"; import { IsArray, IsEnum, @@ -10,10 +10,12 @@ import { IsUUID, MaxLength, Min, -} from 'class-validator'; +} from "class-validator"; export class RecordCheckpointDto { - @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @ApiProperty({ + description: "Station position along the route (0 = origin).", + }) @IsInt() @Min(0) sequenceNo!: number; @@ -31,7 +33,7 @@ export class RecordCheckpointDto { @ApiProperty({ required: false, description: - 'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.', + "ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -42,7 +44,10 @@ export class RecordCheckpointDto { * loading and unloading time. All optional: a stop logged without them still * records its staying time. */ - @ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' }) + @ApiProperty({ + required: false, + description: "ISO timestamp; unloading start.", + }) @IsOptional() @IsISO8601() unloadingStartedAt?: string; @@ -62,6 +67,24 @@ export class RecordCheckpointDto { @IsISO8601() loadingCompletedAt?: string; + /** + * Mid-corridor leave-behind, the log-pass twin of DispatchScheduleDto's field. + * Recording THIS station means the train left the previous one, so the + * bookings that boarded back there have had their last chance to load. When + * present, only these ride on; every other unloaded boarder of the departed + * yard is deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = nobody is left behind, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + "Bookings from the yard just departed confirmed loaded; the rest are unassigned back to the pool. Omit to leave nobody behind.", + }) + @IsOptional() + @IsArray() + @IsUUID("4", { each: true }) + loadedBookingIds?: string[]; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -73,7 +96,8 @@ export class RecordCheckpointDto { export class UpdateCheckpointDto { @ApiProperty({ required: false, - description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.', + description: + "ISO timestamp. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -110,7 +134,8 @@ export class UpdateCheckpointDto { export class DispatchScheduleDto { @ApiProperty({ required: false, - description: 'Actual departure time; defaults to now. Past allowed, future rejected.', + description: + "Actual departure time; defaults to now. Past allowed, future rejected.", }) @IsOptional() @IsISO8601() @@ -125,10 +150,10 @@ export class DispatchScheduleDto { @ApiProperty({ required: false, description: - 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + "Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.", }) @IsOptional() @IsArray() - @IsUUID('4', { each: true }) + @IsUUID("4", { each: true }) loadedBookingIds?: string[]; } 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 9e0489012..3d4f3d2ba 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 @@ -2992,9 +2992,27 @@ export class TrainSchedulingService { } // The train is out — every pinned wagon is ASSIGNED to this schedule and // stays pinned so no other schedule can pick it while it's rolling. - const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) + const pinnedDispatchIds = (schedule.trainSet?.wagons ?? []) .map((slot) => slot.physicalWagonId) .filter((id): id is string => Boolean(id)); + // A built train rolls with its WHOLE consist, not just the slots that + // carry cargo: an empty wagon coupled to the train is physically leaving + // the yard too. Binding only the pinned slots left those empties behind + // on `current_train_schedule_id`, so the checkpoint position fix (which + // filters on exactly that column) never moved them and they stayed + // recorded at the origin yard while the train they are hooked to + // travelled the corridor. + const consistPhysicalIds = schedule.trainSet?.trainId + ? ( + await manager.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + select: { id: true }, + }) + ).map((w) => w.id) + : []; + const dispatchedPhysicalIds = [ + ...new Set([...pinnedDispatchIds, ...consistPhysicalIds]), + ]; if (dispatchedPhysicalIds.length) { await manager .getRepository(Wagon) @@ -3064,6 +3082,15 @@ export class TrainSchedulingService { // that the operator didn't load individually are auto-loaded now — the // train is leaving with them. Mid-corridor boarders stay PAID until the // operator loads them at their own yard. + // + // When the client sends the confirmed list, loading is a MANUAL decision: + // only the ticked bookings are stamped loaded. Anything unticked was + // already unassigned above, but a booking can also sit here unticked and + // still attached (government, or one this predicate cannot shed) — those + // must not be auto-loaded, or an empty wagon rides as if it carried cargo. + // Absent (older clients) = auto-load every origin boarder, the historic + // behavior. + const confirmedLoadedIds = dto.loadedBookingIds; await manager.query( `UPDATE freight.bookings b SET status = 'IN_TRANSIT', @@ -3075,8 +3102,14 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL - AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`, - [scheduleId, schedule.originStationId, now], + AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) + AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`, + [ + scheduleId, + schedule.originStationId, + now, + confirmedLoadedIds ? confirmedLoadedIds : null, + ], ); // Close the booking window; any still-pending (unallocated) reservations don't ride this train. await manager @@ -3174,11 +3207,19 @@ export class TrainSchedulingService { context: { action: string; yardLabel?: string }, ): Promise { if (!schedule.trainSetId) return; - const rows: Array<{ reference: string; loaded: string; total: string }> = - await this.dataSource.query( - `SELECT b.reference, + const rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }> = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference, COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded, - COUNT(*) AS total + COUNT(*) AS total, + ARRAY_AGG(a.id) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) + AS "unloadedAllocationIds" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.bookings b ON b.id = a.booking_id @@ -3190,18 +3231,38 @@ export class TrainSchedulingService { GROUP BY b.id, b.reference HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, - [schedule.trainSetId, boardingYardId], - ); + [schedule.trainSetId, boardingYardId], + ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; - throw new BadRequestException( - `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + // The message stays human-readable for logs and older clients, but the + // payload carries the machine-readable cut so the UI can offer the + // EDR-fault / customer-fault decision instead of parsing prose. + throw new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + code: 'PARTIALLY_LOADED_BOOKINGS', + message: + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + `rebookable) first: ${detail}`, - ); + partiallyLoaded: { + scheduleId: schedule.id, + boardingYardId, + yardLabel: context.yardLabel ?? null, + action: context.action, + bookings: rows.map((r) => ({ + bookingId: r.bookingId, + reference: r.reference, + loadedWagons: Number(r.loaded), + totalWagons: Number(r.total), + unloadedAllocationIds: r.unloadedAllocationIds ?? [], + })), + }, + }); } } @@ -3228,6 +3289,20 @@ export class TrainSchedulingService { } } + /** + * The log-pass twin of {@link unloadedOriginBoarderIds}: bookings that boarded + * at `yardId` and are still unloaded once the train has left it. Same + * predicate — partially-loaded bookings (loading_started_at set) are excluded + * because assertNoPartiallyLoadedBookings resolves those first, and government + * bookings can never be shed. + */ + private async unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise { + return this.unloadedOriginBoarderIds(scheduleId, yardId); + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -4998,6 +5073,24 @@ export class TrainSchedulingService { // skipped checkpoint log cannot smuggle an unresolved yard past the gate. await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); + // Mid-corridor leave-behind. Recording THIS station means the train has + // left the previous one, so cargo that boarded back there has had its last + // chance to load: anything the operator did not tick is deallocated and + // returned to the pool, exactly as dispatch does for the origin yard. + // Origin (seq 0) is dispatch's job, so only seq >= 1 has a departed yard. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = stations.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedBoarderIdsAtYard(scheduleId, departedYardId); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await this.unassignBooking(scheduleId, bookingId, undefined); + } + } + } + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, @@ -5689,6 +5782,22 @@ export class TrainSchedulingService { ); } } + // Consist-only empties: coupled to the built train and bound at dispatch + // so the checkpoint position fix moves them, but they own no slot, so the + // per-slot settle above never sees them. Release them here or they stay + // locked to a finished schedule and no later train can pick them up. + // They carry no cargo, so they simply settle where the train ended up. + await manager + .getRepository(Wagon) + .createQueryBuilder() + .update(Wagon) + .set({ + currentTrainScheduleId: null, + trainSetWagonId: null, + currentYardId: schedule.destinationStationId, + }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + .execute(); if (arrivalLogRows.length) { await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx index d09cf06c8..d6b8afc5d 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx @@ -1,6 +1,7 @@ import { Alert, Button, + Checkbox, Divider, Group, Loader, @@ -24,6 +25,11 @@ import { useEffect, useState } from "react"; import { Freight } from "@edr/types"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { + PartiallyLoadedDecisionModal, + parsePartiallyLoaded, + type PartiallyLoadedPayload, +} from "@/components/trainScheduling/PartiallyLoadedDecisionModal"; import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls"; import { Chip } from "./trackPrimitives"; import { DIRECTION_TONE, track as T } from "./trackTheme"; @@ -98,6 +104,7 @@ export function LogPassYardWorkModal({ onClose, scheduleId, station, + stations, isFinal, alreadyLogged, }: { @@ -105,6 +112,8 @@ export function LogPassYardWorkModal({ onClose: () => void; scheduleId: string; station: TrackStation | null; + /** Whole corridor — used to find the yard the train has just left. */ + stations: TrackStation[]; isFinal: boolean; /** True when opened for the current station (pass already logged). */ alreadyLogged: boolean; @@ -151,6 +160,33 @@ export function LogPassYardWorkModal({ const loadingStarted = Boolean(workLog?.loading?.startedAt); const unloadingStarted = Boolean(workLog?.unloading?.startedAt); + // The yard the train is LEAVING by logging this pass. Its boarders have had + // their last chance to load, so this is where the leave-behind decision is + // made. The origin (seq 0) belongs to dispatch, so there is nothing before it. + const departedStation = + station && station.sequenceNo > 0 + ? stations.find((s) => s.sequenceNo === station.sequenceNo - 1) + : undefined; + const departedYard = departedStation + ? yardWorkQuery.data?.yards.find((y) => y.yardId === departedStation.yardId) + : undefined; + const departedPendingBoarders: YardWorkBookingRow[] = (departedYard?.toLoad ?? []).filter( + (r) => !r.loadedAt, + ); + // Ticked = rides on. Seeded to everyone each time the modal opens on a new + // station, so the default is the historic "nobody is left behind". + const [departedRidingIds, setDepartedRidingIds] = useState>(new Set()); + useEffect(() => { + setDepartedRidingIds(new Set(departedPendingBoarders.map((r) => r.id))); + // Re-seed when the station changes or the rows finish loading. + }, [station?.sequenceNo, opened, departedPendingBoarders.length]); + const departedLeftBehind = departedPendingBoarders.filter( + (r) => !r.isGovernment && !departedRidingIds.has(r.id), + ); + // Set when the pass is rejected because a booking at the departed yard is + // part-loaded — drives the EDR-fault / customer-fault decision. + const [partialGate, setPartialGate] = useState(null); + const doLogPass = () => { if (!station) return; recordCheckpoint.mutate( @@ -159,11 +195,30 @@ export function LogPassYardWorkModal({ payload: { sequenceNo: station.sequenceNo, ...(passAt ? { occurredAt: passAt.toISOString() } : {}), + // Logging THIS station means the train left the previous one, so the + // cargo that boarded back there has had its last chance to load. + // Only the ticked ones ride on; the rest are unassigned and returned + // to the pool. Government bookings always ride — the server refuses + // to unassign them. + ...(departedYard + ? { + loadedBookingIds: departedPendingBoarders + .filter((r) => r.isGovernment || departedRidingIds.has(r.id)) + .map((r) => r.id), + } + : {}), }, }, { onSuccess: () => { setJustLogged(true); + if (departedLeftBehind.length) { + toast({ + title: `${departedLeftBehind.length} booking${departedLeftBehind.length === 1 ? "" : "s"} left behind at ${departedYard?.yard ?? "the previous yard"}`, + description: + "Removed from this train — wagons freed, bookings returned to the pool for a later schedule.", + }); + } toast({ title: isFinal ? "Train arrived — remaining bookings marked arrived, assets freed" @@ -178,12 +233,21 @@ export function LogPassYardWorkModal({ }); void yardWorkQuery.refetch(); }, - onError: (err) => + onError: (err) => { + // A part-loaded booking at the departed yard blocks the pass until + // its never-loaded wagons are cut — offer the fault decision instead + // of a dead-end error. + const gate = parsePartiallyLoaded(err); + if (gate) { + setPartialGate(gate); + return; + } toast({ title: "Could not log checkpoint", description: parseError(err, "Please try again"), variant: "destructive", - }), + }); + }, }, ); }; @@ -558,6 +622,66 @@ export function LogPassYardWorkModal({ )} + {!logged && departedStation && departedPendingBoarders.length > 0 ? ( + + + + + {departedPendingBoarders.length} booking + {departedPendingBoarders.length === 1 ? "" : "s"} not loaded at{" "} + {departedStation.label} + + + + Logging this pass means the train has left {departedStation.label}. Untick + anything that never made it onto the train — it is removed and returned to the + booking pool. + + {departedPendingBoarders.map((r) => ( + + { + const next = new Set(departedRidingIds); + if (e.currentTarget.checked) next.add(r.id); + else next.delete(r.id); + setDepartedRidingIds(next); + }} + label={ + + {r.reference ?? r.id} + {r.isGovernment ? ( + + {" "} + · government, cannot be removed + + ) : null} + + } + /> + + {r.customer} + + + ))} + {departedLeftBehind.length ? ( + + {departedLeftBehind.length} booking + {departedLeftBehind.length === 1 ? "" : "s"} will be removed from this train. + + ) : null} + + ) : null} + {!logged ? ( + {/* Part-loaded gate. Cutting the wagons does NOT log the pass — the + operator confirms the pass again once the consist is clean. */} + setPartialGate(null)} + onResolved={() => void yardWorkQuery.refetch()} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PartiallyLoadedDecisionModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PartiallyLoadedDecisionModal.tsx new file mode 100644 index 000000000..ff083faea --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PartiallyLoadedDecisionModal.tsx @@ -0,0 +1,250 @@ +import { + Alert, + Button, + Divider, + Group, + Modal, + Paper, + Radio, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { AlertTriangle, PackageX } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; + +/** + * One partially-loaded booking, as the server reports it in the + * PARTIALLY_LOADED_BOOKINGS error payload. + */ +export interface PartiallyLoadedBooking { + bookingId: string; + reference: string; + loadedWagons: number; + totalWagons: number; + unloadedAllocationIds: string[]; +} + +export interface PartiallyLoadedPayload { + scheduleId: string; + boardingYardId: string; + yardLabel: string | null; + action: string; + bookings: PartiallyLoadedBooking[]; +} + +/** + * Reads the structured `partiallyLoaded` block off a rejected dispatch or + * log-pass. Returns null for every other error so callers can fall through to + * their normal toast. + */ +export function parsePartiallyLoaded( + error: unknown, +): PartiallyLoadedPayload | null { + const data = ( + error as { + response?: { + data?: { code?: string; partiallyLoaded?: PartiallyLoadedPayload }; + }; + } + )?.response?.data; + if (data?.code !== "PARTIALLY_LOADED_BOOKINGS") return null; + return data.partiallyLoaded ?? null; +} + +type Fault = "EDR" | "CUSTOMER"; + +/** + * The gate a partly-loaded booking hits at dispatch or log-pass. + * + * A booking with some wagons loaded and some never loaded can neither ride + * (the empty wagons would leave as ghosts) nor be left behind (unassigning + * would strand cargo physically on the train). So the operator decides here: + * cut the never-loaded wagons at EDR's fault (no fee, rebookable credit) or + * the customer's (cancellation fee invoiced) — or block, and go finish loading. + * + * Cancelling does NOT then log the pass. The modal closes, the caller refetches, + * and the operator clicks their action again with the gate cleared — two + * deliberate commits rather than one compound one. + */ +export function PartiallyLoadedDecisionModal({ + payload, + onClose, + onResolved, +}: { + payload: PartiallyLoadedPayload | null; + onClose: () => void; + /** Cut succeeded — refetch, so the retry sees the cleared gate. */ + onResolved: () => void; +}) { + const { toast } = useToast(); + const [fault, setFault] = useState(null); + const [reason, setReason] = useState(""); + const cancelRemaining = useMutation( + api.trainScheduling.cancelRemainingWagons.mutationOptions(), + ); + + useEffect(() => { + setFault(null); + setReason(""); + }, [payload?.boardingYardId, payload?.bookings.length]); + + if (!payload) return null; + + const { bookings, yardLabel, action } = payload; + const totalUnloaded = bookings.reduce( + (n, b) => n + b.unloadedAllocationIds.length, + 0, + ); + const where = yardLabel ? ` at ${yardLabel}` : ""; + + const submit = async () => { + if (!fault) return; + const edrFault = fault === "EDR"; + try { + // Cut every partly-loaded booking's never-loaded wagons under the one + // decision — they are all stuck behind the same gate for the same reason. + for (const b of bookings) { + await cancelRemaining.mutateAsync({ + bookingId: b.bookingId, + scheduleId: payload.scheduleId, + reason: reason.trim(), + edrFault, + wagonAllocationIds: b.unloadedAllocationIds, + }); + } + toast({ + title: `${totalUnloaded} wagon${totalUnloaded === 1 ? "" : "s"} cancelled`, + description: edrFault + ? "EDR's fault — no fee charged; the credit is rebookable." + : "Customer's fault — the cancellation fee was invoiced; the credit is rebookable.", + }); + onResolved(); + onClose(); + } catch (err) { + const message = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + toast({ + title: "Cancellation failed", + description: Array.isArray(message) + ? message.join("; ") + : message || (err as Error)?.message || "Please try again", + variant: "destructive", + }); + } + }; + + return ( + + + Partly loaded — a decision is needed + + } + > + + } + title={`Cannot ${action}${where}`} + > + + A booking with some wagons loaded and some never loaded can neither + ride nor be removed — the loaded cargo is physically on the train. + + + + + {bookings.map((b) => ( + + + + {b.reference} + + + {b.loadedWagons} of {b.totalWagons} wagons loaded + + + + {b.unloadedAllocationIds.length} wagon + {b.unloadedAllocationIds.length === 1 ? "" : "s"} never loaded — + to be cancelled. The {b.loadedWagons} loaded wagon + {b.loadedWagons === 1 ? "" : "s"} stay on the train. + + + ))} + + + + + setFault(v as Fault)} + > + + + + + + + {fault ? ( +