From 835c9e111ccb8c94506265526347c0943d4e169a Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 21 Jul 2026 23:02:06 +0000 Subject: [PATCH] feat(train-scheduling): implement container movement between wagons - Added functionality to move containers between wagons in the train scheduling system. - Introduced API endpoint and service method to handle container movement. - Updated component to support drag-and-drop for rearranging containers. - Enhanced to allow moving containers to other wagons via a context menu. - Implemented UI feedback for container movement actions, including loading states and success/error notifications. - Updated relevant types and constants to accommodate new container movement logic. - Added tests for the rule engine to ensure proper handling of hazardous bookings. --- .../bookings/booking-pricing.service.spec.ts | 44 +- .../bookings/booking-pricing.service.ts | 39 +- .../contracts/contract-booking.service.ts | 24 +- .../entities/clearance-incident.entity.ts | 1 + .../rule-engine/rule-engine.service.spec.ts | 78 ++++ .../rule-engine/rule-engine.service.ts | 49 ++- .../booking-batch.service.spec.ts | 214 ++++++++++ .../train-scheduling/booking-batch.service.ts | 121 ++++-- .../booking-window.service.spec.ts | 2 + .../booking-window.service.ts | 5 + .../dto/move-container-item.dto.ts | 14 + .../train-scheduling/intercity.service.ts | 13 +- .../train-scheduling.controller.ts | 14 + .../train-scheduling.service.spec.ts | 140 ++++++ .../train-scheduling.service.ts | 397 ++++++++++++++++-- .../wagon-plan-flex.util.spec.ts | 112 +++++ .../train-scheduling/wagon-plan-flex.util.ts | 93 +++- .../train-scheduling/wagon-plan.util.ts | 34 ++ .../gl-actions/IncidentReportCard.tsx | 2 + .../IntercityRideAlongPanel.tsx | 33 +- .../ScheduleWorkspacePanel.tsx | 30 ++ .../trainScheduling/WagonPlanGrid.tsx | 5 +- .../trainScheduling/YardWorkPanel.tsx | 13 +- .../InteractiveTrainConsist.tsx | 148 ++++++- .../compositionEditor/TrainConsistView.tsx | 54 ++- .../compositionEditor/WagonCard.tsx | 103 ++++- .../trainScheduling/scheduleVisuals.tsx | 132 ++++++ .../backoffice/src/constants/URLS.ts | 2 + .../pages/contracts/GlClearanceDetailPage.tsx | 24 ++ .../BatchScheduleDetailPage.tsx | 4 +- .../TrainScheduleV2DetailPage.tsx | 62 ++- .../backoffice/src/services/api.ts | 20 + .../src/services/trainScheduling.service.ts | 12 + .../backoffice/src/types/trainScheduling.ts | 11 + packages/types/src/freight/contracts.ts | 1 + 35 files changed, 1896 insertions(+), 154 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 3f4f64186..fecd9111a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => { computeBaseRailLinesWithRates: ( b: Booking, input: { containers: [] }, - ) => Promise<{ lineItems: Array<{ amount: number }> }>; + ) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>; } ).computeBaseRailLinesWithRates(booking, { containers: [] }); expect(result.lineItems).toHaveLength(0); + expect(result.blocked).toHaveLength(1); }); it('does not price containers off a rate configured for a different leg', async () => { @@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems).toHaveLength(0); }); + + // A mixed booking where only one container size has a configured rate must + // hard-block, not silently carry the unconfigured size for free. + it('blocks the unconfigured container size and prices the configured one', async () => { + const fortyOnly: Rate = { + ...intercityContainerUsd, + id: 'rate-ct-40-only', + containerTypeId: 'ct-40', + } as Rate; + ratesService.findLiveRates.mockResolvedValue([fortyOnly]); + + const booking = { + id: 'b-5', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [ + { containerTypeId: 'ct-40', quantity: 2 }, + { containerTypeId: 'ct-20', quantity: 3 }, + ], + }); + + expect(result.lineItems).toHaveLength(1); + expect(result.blocked).toHaveLength(1); + expect(result.blocked[0]).toContain('rate is configured'); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index a42d23859..32aa1a880 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -137,8 +137,12 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } = - await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); + const { + lineItems: baseLines, + usedRates: baseRates, + warnings: baseWarnings, + blocked: baseBlocked, + } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -248,7 +252,7 @@ export class BookingPricingService { appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: [...ruleResult.warnings, ...baseWarnings], - hardBlocked: ruleResult.hardBlocked, + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], overweightLines, }; } @@ -454,7 +458,12 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> { + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -477,6 +486,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); const warnings: string[] = []; + const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { @@ -500,11 +510,14 @@ export class BookingPricingService { const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { // Never price this line off another container type's (or another - // route's) rate — an unpriced line with a warning is recoverable; a - // silently mischarged one is not. - warnings.push( + // route's) rate, and never let an unpriced line through: a booking + // that ships a container type nobody configured a rate for would be + // carried for free. Hard-block instead — the customer drops the line + // or EDR configures the rate. + blocked.push( `No ${rateType} rate is configured for ${label} on this route — ` + - 'the line was not priced.', + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', ); continue; } @@ -587,10 +600,18 @@ export class BookingPricingService { quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); + } else if (isBulk) { + // Same rule as container lines: bulk freight with no rate on this leg + // must not proceed unpriced. + blocked.push( + `No ${rateType} rate is configured for this route — the booking ` + + 'cannot be priced. Ask EDR to configure the rate for this ' + + 'origin → destination.', + ); } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** 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 90567c055..a4360447d 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 @@ -320,6 +320,12 @@ export class ContractBookingService { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has + // a positive total, so the zero-price gate below misses it — enforce + // the pricing hard blocks first. The catch below rolls everything back. + if (computed.hardBlocked.length > 0) { + throw new BadRequestException(computed.hardBlocked.join('; ')); + } // Reject a zero-price booking outright. A total of 0 means no contract rate // matched the route/container (or the rate is unset), so the booking is not // valid to ship or invoice. The catch below rolls back the row + its lines. @@ -744,15 +750,20 @@ export class ContractBookingService { const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once - // the contract rates are fixed (the clearance work is not lost). - if (!(computed.totalAmount > 0)) { + // the contract rates are fixed (the clearance work is not lost). A + // pricing hard block (e.g. one of two container sizes has no rate) + // rolls back the same way: a partially-priced total is positive but + // the booking must not proceed. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join('; ') + : 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { @@ -1896,7 +1907,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors: [...scopeErrors, ...capacityErrors], + // Pricing hard blocks (missing rate for a container size / requested + // service) ride the capacity-errors channel so the form hard-blocks in + // the preview instead of failing at the create call. + capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts index 6d2afce3c..2ece44f12 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [ 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', + 'OTHER', ] as const; export type IncidentType = (typeof INCIDENT_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts new file mode 100644 index 000000000..85c7db4a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -0,0 +1,78 @@ +import { RuleEngineService } from './rule-engine.service'; +import type { BookingEvaluationInput } from './rule-engine.service'; +import type { Rate } from './entities/rate.entity'; + +describe('RuleEngineService — requested service without a configured surcharge rate', () => { + const hazardRate: Rate = { + id: 'rate-hazard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let ratesRepo: { findLiveRates: jest.Mock }; + let service: RuleEngineService; + + beforeEach(() => { + ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) }; + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes + { findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits + { findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs + ratesRepo as never, + { findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines + {} as never, // dataSource (unused by evaluate) + ); + }); + + const input = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + containers: [], + ...overrides, + }); + + it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => { + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); + + it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => { + ratesRepo.findLiveRates.mockResolvedValue([hazardRate]); + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('does not block a non-hazardous booking when no surcharge rates exist', async () => { + const result = await service.evaluate(input({})); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => { + const result = await service.evaluate( + input({ + containers: [ + { + containerTypeId: 'ct-20', + quantity: 2, + vgmPerUnitTons: 10, + totalVgmTons: 20, + reeferQuantity: 1, + }, + ], + }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('reefer'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 9d7aa6ba9..3a4c4b778 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -28,6 +28,10 @@ import { } from './interfaces/shipping-lines.repository.interface'; import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; +// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. +// from multipart form-data) and a non-empty "false" string is truthy. +const truthy = (v: unknown): boolean => v === true || v === 'true'; + export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; @@ -245,6 +249,48 @@ export class RuleEngineService { liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); + // A handling service the booking asks for (booking-level flag OR any + // per-container opt-in count) with no LIVE surcharge rate configured is a + // hard block — pricing would otherwise ship the service for free. System- + // derived charges (consolidation, overweight, shipping line, lashing) stay + // exempt: the customer never opted into those, so they must not block. + const requestedServices: Array<{ + trigger: RateTrigger; + wanted: boolean; + label: string; + }> = [ + { + trigger: 'HAZARDOUS', + wanted: + truthy(input.isHazardous) || + input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), + label: 'hazardous cargo', + }, + { + trigger: 'REEFER', + wanted: + hasReefer || + input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), + label: 'refrigerated (reefer) cargo', + }, + { + trigger: 'WITH_RETURN', + wanted: + truthy(input.withReturn) || + input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), + label: 'empty-container return', + }, + ]; + for (const svc of requestedServices) { + if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { + hardBlocked.push( + `No ${svc.label} surcharge rate is configured — the booking cannot ` + + `be priced with this service. Remove the ${svc.label} option or ` + + 'ask EDR to configure its rate.', + ); + } + } + for (const rate of surchargeRates) { const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, @@ -438,9 +484,6 @@ export class RuleEngineService { hasLashing: boolean; }, ): boolean { - // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. - // from multipart form-data) and a non-empty "false" string is truthy. - const truthy = (v: unknown): boolean => v === true || v === 'true'; switch (trigger) { case 'HAZARDOUS': return truthy(state.isHazardous); 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 fc97f772b..bafc7a13e 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 @@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => { }); }); + describe('expireLeftoverExportDay — export day sweep', () => { + const exportSchedule = { + id: scheduleId, + direction: 'EXPORT', + originStationId: 'yard-origin', + destinationStationId: 'yard-dest', + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + }; + let unacceptedSpy: jest.SpyInstance; + let poolSpy: jest.SpyInstance; + + beforeEach(() => { + unacceptedSpy = jest + .spyOn(service, 'expireUnacceptedForRouteDay') + .mockResolvedValue(undefined); + poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0); + }); + + it('ignores non-export schedules', async () => { + trainSchedulesRepository.findById.mockResolvedValue({ + ...exportSchedule, + direction: 'IMPORT', + }); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while another export train on the day can still take bookings', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while a FULL train still has live pay windows', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + bookingsRepository.findReservedForSchedule.mockResolvedValue([ + { + paymentStatus: 'PENDING', + status: 'AWAITING_PAYMENT', + paymentDeadline: new Date(Date.now() + 60_000), + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).toHaveBeenCalledWith({ + originYardId: 'yard-origin', + destinationYardId: 'yard-dest', + day: '2026-06-20', + }); + expect(poolSpy).toHaveBeenCalledWith(scheduleId); + }); + }); + describe('maybeOfferPartial — split-eligibility gate', () => { const importGeneral = { id: 'b1', @@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => { ); }); }); + + describe('acceptIntercity — export pay window expires at window close', () => { + const exportScheduleId = 'export-train'; + // Window closes in 30 minutes; the configured pay window is 60 minutes. + const closesAt = new Date(Date.now() + 30 * 60_000); + + const waiting = { + id: 'ic-1', + reference: 'IC-1', + isGovernment: false, + status: 'FULLY_EXECUTED', + trainScheduleId: null, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 10, + bookingContainers: [], + } as unknown as Booking; + + let scheduleRepo: { findOne: jest.Mock }; + let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock }; + + beforeEach(() => { + bookingRepo = dataSource.getRepository(); + bookingRepo.findOne.mockResolvedValue(waiting); + scheduleRepo = { findOne: jest.fn() }; + // reserve() reads the target schedule to clamp export deadlines — route + // TrainSchedule reads to their own repo, everything else stays as before. + dataSource.getRepository.mockImplementation((entity?: { name?: string }) => + entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo, + ); + }); + + it('clamps the intercity pay deadline to the export window close', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: closesAt, + scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000), + }); + + await service.acceptIntercity(waiting, exportScheduleId); + + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ + status: 'SELECTED_FOR_BATCH', + paymentDeadline: closesAt, + }), + ); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + }); + + it('keeps the plain payment window on import trains', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 'import-train', + direction: 'IMPORT', + windowClosesAt: closesAt, + }); + + await service.acceptIntercity(waiting, 'import-train'); + + const deadline = ( + bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date } + ).paymentDeadline; + // 60-minute pay window runs past the 30-minutes-out close: no clamp. + expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime()); + }); + + it('rejects an accept after the export window closed — no pay window opens', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: new Date(Date.now() - 60_000), + }); + + await expect( + service.acceptIntercity(waiting, exportScheduleId), + ).rejects.toThrow(/window has closed/); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.payNow).not.toHaveBeenCalled(); + }); + + it('expires an unpaid export ride-along at close and frees the train', async () => { + const lapsed = { + ...(waiting as unknown as Record), + status: 'SELECTED_FOR_BATCH', + trainScheduleId: exportScheduleId, + paymentDeadline: new Date(Date.now() - 1_000), + originYardId: 'yard-a', + destinationYardId: 'yard-b', + priorityScore: 0, + wagonsRequired: 1, + } as unknown as Booking; + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // expire()'s paid-guard re-reads the booking fresh — still unpaid. + bookingRepo.findOne.mockResolvedValue(lapsed); + trainSchedulesRepository.findById.mockResolvedValue({ + id: exportScheduleId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'DONE', + scheduledDepartureDate: new Date(Date.now() + 3_600_000), + originStationId: 'yard-a', + destinationStationId: 'yard-b', + }); + + await service.settleDueReservations(exportScheduleId); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }), + ); + }); + }); }); describe('BookingBatchService — wagonsFor', () => { 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 415b5df97..03a125aef 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 @@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // This payment may have been the last live pay window on a now-full + // export day — the settle that normally re-runs the sweep finds nothing + // left to settle, so trigger it here. + void this.expireLeftoverExportDay(booking.trainScheduleId); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit { `— payment phase extended for them`, ); } + // The settle may have resolved the last pay window on a full export day + // (paid → allocated, and the top-up found nothing else that fits) — sweep + // the date's leftover bookings. Self-guarded: no-op for import/domestic + // and while any train on the day can still take bookings. + await this.expireLeftoverExportDay(scheduleId); // Emitted here (not in settleDueReservations/settleBatch, which both wrap // this) so one settle produces one push, after every allocation/expiry/ // top-up extension for this schedule has been persisted. @@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // Same as the webhook path: a staff mark-paid can settle the last live + // pay window on a now-full export day — sweep the date's leftovers. + void this.expireLeftoverExportDay(booking.trainScheduleId); } void this.triggerWagonAllocation(booking.trainScheduleId!); this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); @@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); - // Built trains: collapse to a single train-wide pool so the freed capacity of - // a booking that alights mid-corridor is NOT re-offered on the pass-through - // leg (see remainingBudget). Keeps intercity accept consistent with the - // train-wide isTrainFull / committedWagons finalize signal. - const budget = await this.remainingBudget(schedule, limits, wagonDims, { - collapseForBuiltTrain: true, - }); + // Built trains use the leg-aware corridor budget too: the wagon planner + // consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon + // that runs empty Gelan→Adama genuinely can carry an intercity booking + // there before its export cargo boards at Adama. A train full on one leg + // still accepts ride-alongs on its empty legs — that is the whole point + // of the ride-along flow. + const budget = await this.remainingBudget(schedule, limits, wagonDims); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + // EXPORT parity: pay windows on an export train never outlive its booking + // window — export bookings expire at close, so anything reserved onto the + // same train (FCFS export or an intercity ride-along) must too. Import + // keeps the plain payment window; its cycles re-fill after settle. + const targetSchedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (targetSchedule?.direction === "EXPORT") { + const cutoff = + targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; + if (cutoff && cutoff.getTime() <= now.getTime()) { + throw new BadRequestException( + "Export booking window has closed — cannot open a pay window on this train", + ); + } + if (cutoff && cutoff.getTime() < deadline.getTime()) { + deadline = new Date(cutoff); + } + } await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit { return leftovers.length; } + /** + * EXPORT counterpart of the conclude-time sweep. Export has no batch cycle, + * so nothing ever concluded its day: bookings still waiting when the trains + * filled up or the window closed stayed pending forever. Once every export + * train on this route-day is shut — window DONE, or FULL with no pay window + * still live that could lapse and free space — the date is dead: expire the + * un-accepted bookings staff can no longer accept AND the ready + * (FULLY_EXECUTED) bookings that never got a reservation (consolidation + * waiters). Runs at export window close and whenever an export train's + * fullness settles. + */ + async expireLeftoverExportDay(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) { + return; + } + const day = eatDay(schedule.scheduledDepartureDate); + const trains = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }) + ).filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day, + ); + for (const s of trains) { + // Any train still taking bookings keeps the date alive. + if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return; + // A FULL train whose reservations are still inside their pay windows can + // reopen when one lapses unpaid — defer; the settle re-runs this sweep. + if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) { + return; + } + } + await this.expireUnacceptedForRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day, + }); + await this.expireLeftoverDayPool(scheduleId); + } + /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings @@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit { schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, - opts?: { collapseForBuiltTrain?: boolean }, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { @@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit { tolerance: { weightTons: 0, lengthMeters: 0 }, }; } - // A built train's wagons are coupled for the WHOLE trip, and the allocator - // commits each booking to a wagon for the entire route — it never reloads a - // wagon at a mid-corridor alight yard. So a built train has no leg concept: - // its capacity is one train-wide pool, exactly as isTrainFull / - // committedWagons already count it. When a caller opts in, collapse the - // corridor to a single whole-route edge so every booking (full-route OR - // mid-corridor) draws from that one pool — a train full of import-to-DireDawa - // then correctly shows NO room for a DireDawa->Addis intercity booking on the - // leg it merely passes through, instead of over-promising the freed slots. - // Locomotive-derived schedules keep the leg-aware multi-edge corridor: their - // abstract slot/weight/length budget genuinely frees past an alight yard. - const stops = - physicalWagons != null && opts?.collapseForBuiltTrain - ? [schedule.originStationId, schedule.destinationStationId] - : await this.stopsForSchedule(schedule); + // Built trains keep the leg-aware multi-edge corridor too: the wagon + // planner consumes stock per edge (planWagonsWithStock legs), so a consist + // wagon serves disjoint legs — capacity freed past an alight yard is real. + const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 9393c520f..abd07c129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; + expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; @@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), + expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 02c5fb993..3b9bb25d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); schedule.bookingWindowStatus = 'CLOSED'; } + // Export has no conclude step: this close is the last moment the day's + // bookings could have boarded. Once every train on the route-day is + // shut, expire what is still waiting for this date (the sweep defers + // while a sibling train stays open). + await this.bookingBatchService.expireLeftoverExportDay(schedule.id); return true; } return false; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts new file mode 100644 index 000000000..c21780d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-container-item.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsUUID } from 'class-validator'; + +export class MoveContainerItemDto { + @IsUUID() + targetTrainSetWagonId!: string; + + /** + * Swap with this container on the target wagon instead of requiring free + * space there. Same-wagon swaps exchange the two slot positions. + */ + @IsUUID() + @IsOptional() + swapWithItemId?: string; +} 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 b35650e16..293fc8801 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 @@ -146,10 +146,8 @@ export class IntercityService { remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; - // legForYards, not legOf: on a built train the budget is a single - // whole-route edge (see intercityCapacity), so a mid-corridor booking - // must draw from that one pool via the whole-route fallback. On a - // locomotive-derived schedule it still resolves to the booking's own leg. + // legForYards: the booking draws only from ITS OWN leg's edges, with a + // whole-route fallback when its yards aren't on the budget's stop list. const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, @@ -215,11 +213,8 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - // legForYards, not legOf: a built train's budget is a single whole-route - // pool (mid-corridor wagons are committed for the whole trip and never - // reloaded), so the booking draws from that pool via the whole-route - // fallback; a locomotive-derived schedule still gets the booking's own - // leg, so it can still board a train that is full only on other legs. + // legForYards: charge only the edges this booking rides, so it can still + // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { rejected.push({ 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 3e142f778..dd9fbcf7c 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 @@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; +import { MoveContainerItemDto } from "./dto/move-container-item.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; @@ -374,6 +375,19 @@ export class TrainSchedulingController { return this.trainSchedulingService.updateContainerItem(id, itemId, dto); } + @Post("schedules/:id/container-items/:itemId/move") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Move a container to another wagon (optionally swapping two containers)", + }) + moveContainerItem( + @Param("id", ParseUUIDPipe) id: string, + @Param("itemId", ParseUUIDPipe) itemId: string, + @Body() dto: MoveContainerItemDto, + ) { + return this.trainSchedulingService.moveContainerItem(id, itemId, dto); + } + @Get("schedules/:id/unassigned-bookings") @TrainSchedulingView() @ApiOperation({ summary: "Get unassigned bookings for a schedule" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index af1a9eb45..8bf26b7f3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -5,6 +5,7 @@ import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -1081,4 +1082,143 @@ describe('TrainSchedulingService', () => { expect(html).not.toContain('EMPTY'); }); }); + + describe('moveContainerItem — staff rearrange', () => { + const wagon1 = { id: 'w1', sequenceNo: 1, capacityTons: 61 }; + const wagon2 = { id: 'w2', sequenceNo: 2, capacityTons: 61 }; + const schedule = { + id: 'sched-1', + status: 'SCHEDULED', + trainSet: { wagons: [wagon1, wagon2] }, + }; + + let sourceAlloc: Record; + let item: Record; + let itemRepo: { findOne: jest.Mock; update: jest.Mock; count: jest.Mock }; + let allocRepo: { + find: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + let wagon2Allocs: Array>; + + beforeEach(() => { + sourceAlloc = { + id: 'alloc-1', + trainSetWagonId: 'w1', + bookingId: 'b1', + allocatedWeightTons: 20, + loadType: 'CONTAINER', + status: 'PLANNED', + containerItems: [], + }; + item = { + id: 'item-1', + wagonBookingAllocationId: 'alloc-1', + positionOnWagon: 1, + grossWeightTons: 20, + containerType: { sizeFt: 20 }, + allocation: sourceAlloc, + }; + sourceAlloc.containerItems = [item]; + wagon2Allocs = []; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + itemRepo = { + findOne: jest.fn().mockResolvedValue(item), + update: jest.fn().mockResolvedValue(undefined), + count: jest.fn().mockResolvedValue(0), + }; + allocRepo = { + find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) => + Promise.resolve(where.trainSetWagonId === 'w1' ? [sourceAlloc] : wagon2Allocs), + ), + findOne: jest.fn().mockImplementation(({ where }: { where: { id?: string } }) => + Promise.resolve(where.id === 'alloc-1' ? { ...sourceAlloc } : null), + ), + create: jest.fn((v: unknown) => v), + save: jest.fn().mockImplementation((v: Record) => + Promise.resolve({ ...v, id: 'alloc-new' }), + ), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + }; + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WagonAllocationContainerItem) return itemRepo; + if (entity === WagonBookingAllocation) return allocRepo; + return { find: jest.fn().mockResolvedValue([]) }; + }); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => + fn({ getRepository: dataSource.getRepository }), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: 'sched-1' } as never); + }); + + it('rejects moves on a dispatched train', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + ...schedule, + status: 'DISPATCHED', + }); + await expect( + service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), + ).rejects.toThrow(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects a target wagon that has no TEU room left', async () => { + wagon2Allocs = [ + { + id: 'alloc-2', + trainSetWagonId: 'w2', + bookingId: 'b2', + allocatedWeightTons: 25, + loadType: 'CONTAINER', + containerItems: [{ id: 'item-40', containerType: { sizeFt: 40 } }], + }, + ]; + await expect( + service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), + ).rejects.toThrow(/no room/); + }); + + it('rejects a bulk-loaded target wagon', async () => { + wagon2Allocs = [ + { + id: 'alloc-2', + trainSetWagonId: 'w2', + bookingId: 'b2', + allocatedWeightTons: 40, + loadType: 'BULK', + containerItems: [], + }, + ]; + await expect( + service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }), + ).rejects.toThrow(/bulk/); + }); + + it('moves a container to an empty wagon and re-homes its allocation', async () => { + await service.moveContainerItem('sched-1', 'item-1', { targetTrainSetWagonId: 'w2' }); + + // A new allocation for the booking was created on the target wagon… + expect(allocRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ trainSetWagonId: 'w2', bookingId: 'b1' }), + ); + // …the container item now hangs off it… + expect(itemRepo.update).toHaveBeenCalledWith('item-1', { + wagonBookingAllocationId: 'alloc-new', + }); + // …and the emptied source allocation was deleted, not left at 0 items. + expect(allocRepo.delete).toHaveBeenCalledWith('alloc-1'); + }); + }); }); 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 93d8bba56..6f154fb81 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 @@ -77,6 +77,7 @@ import { TrainScheduleFreightType, } from './dto/list-train-schedules-query.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { MoveContainerItemDto } from './dto/move-container-item.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; @@ -122,7 +123,7 @@ import { sumWagonsRequired, type TrainLimitConfig, validateContainerPlacements, - validateMixedTrainLimits, + validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; @@ -1538,8 +1539,21 @@ export class TrainSchedulingService { } } + // The rebuild below deletes EVERY schedule↔booking link row and recreates + // only what makes the new plan. Ride-along (intercity) bookings are linked + // OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the + // workspace's picked ids, so planning from dto.bookingIds alone silently + // orphans them: PAID + SCHEDULED with no link and no wagon, invisible in + // every list. Every (re)assignment therefore re-plans the WHOLE train: + // the requested ids plus everything currently linked. + const linkedRows = + await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId); + const allBookingIds = [ + ...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]), + ]; + const previewDto = { - bookingIds: dto.bookingIds, + bookingIds: allBookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, @@ -1562,8 +1576,13 @@ export class TrainSchedulingService { // 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"). + // Callers hand-pick placements only for the bookings they know about; the + // union above may have folded in linked ride-alongs those placements never + // covered. Auto-fill whatever units are missing (all of them when no + // placements were sent at all) so the placement validator doesn't reject + // container bookings the caller couldn't have placed. let containerPlacements = dto.containerPlacements; - if (!containerPlacements?.length) { + { const preview = await this.validateBookingsForScheduling( previewDto, freightType ?? null, @@ -1578,18 +1597,28 @@ export class TrainSchedulingService { ); 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), - }); + const providedKeys = new Set( + (containerPlacements ?? []).map( + (p) => `${p.bookingContainerId}:${p.unitIndex}`, + ), + ); + const unplacedUnits = units.filter( + (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`), + ); + if (unplacedUnits.length) { + const slots = getContainerSlotSequenceNos(preview.wagonPlan); + const generated = autoFillPlacements(unplacedUnits, slots); + const missing = findMissingContainerNumberIssues(unplacedUnits, generated); + if (missing.length) { + throw new BadRequestException({ + message: `Booking validation failed: ${missing + .map((m) => m.issue) + .join('; ')}`, + violations: missing.map((m) => m.issue), + }); + } + containerPlacements = [...(containerPlacements ?? []), ...generated]; } - containerPlacements = generated; } } @@ -1632,8 +1661,10 @@ export class TrainSchedulingService { // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op. // A stock shortage is a physical impossibility, so forceAssign cannot // override it either. + // Linked ride-alongs count as requested too: silently dropping one here is + // exactly the delete-and-recreate orphan this method must never produce. const plannedIds = new Set(validation.bookings.map((b) => b.id)); - const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id)); + const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id)); if (droppedRequested.length) { const reasonById = new Map( validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]), @@ -3852,25 +3883,24 @@ export class TrainSchedulingService { ); } + // Corridor-aware: a booking belongs on this train when its origin and + // destination lie on the schedule's stop list in order — sub-corridor + // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The + // stop list is also what makes the wagon plan leg-aware below. + let stops = [dto.originStationId, dto.destinationStationId]; + if (targetScheduleId) { + const target = await this.trainSchedulesRepository.findById(targetScheduleId); + if (target) stops = await this.stopYardsForSchedule(target); + } if ( - await (async () => { - // Corridor-aware: a booking belongs on this train when its origin and - // destination lie on the schedule's stop list in order — sub-corridor - // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. - let stops = [dto.originStationId, dto.destinationStationId]; - if (targetScheduleId) { - const target = await this.trainSchedulesRepository.findById(targetScheduleId); - if (target) stops = await this.stopYardsForSchedule(target); + bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; } - return bookings.some((b) => { - if (targetScheduleId && b.trainScheduleId === targetScheduleId) { - return false; - } - const fromIdx = stops.indexOf(b.originYardId); - const toIdx = stops.indexOf(b.destinationYardId); - return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; - }); - })() + const fromIdx = stops.indexOf(b.originYardId); + const toIdx = stops.indexOf(b.destinationYardId); + return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; + }) ) { violations.push('Selected bookings must lie on the schedule route (origin before destination)'); } @@ -3956,7 +3986,22 @@ export class TrainSchedulingService { stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - const planned = planWagonsWithStock({ bookings, allowed, stock }); + // Leg-aware stock: each booking consumes wagons only on the edges it rides, + // so a ride-along on an empty leg never competes with cargo on a full one. + const legByBookingId = new Map( + bookings.flatMap((b) => { + const from = stops.indexOf(b.originYardId); + const to = stops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); + const planned = planWagonsWithStock({ + bookings, + allowed, + stock, + legs: legByBookingId, + edgeCount: Math.max(1, stops.length - 1), + }); violations.push(...planned.configIssues); const fittingBookings = planned.fitting; const deferredBookings: DeferredBookingRow[] = planned.deferred; @@ -4018,10 +4063,11 @@ export class TrainSchedulingService { ).values(), ]; pushLimit( - validateMixedTrainLimits( + validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, + stops, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -6810,12 +6856,33 @@ export class TrainSchedulingService { status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, + // Which leg of the corridor this booking rides — the workspace can't + // tell a ride-along (intercity) or sub-corridor booking from through + // cargo without it. + tradeDirection: sb.booking?.tradeDirection ?? null, + originYardId: sb.booking?.originYardId ?? null, + destinationYardId: sb.booking?.destinationYardId ?? null, + origin: + sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null, + destination: + sb.booking?.destinationYard?.label ?? + sb.booking?.destinationYard?.code ?? + null, + wagonsRequired: + sb.booking?.wagonsRequired != null + ? Number(sb.booking.wagonsRequired) + : null, + loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, + arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? 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), })) ?? [], + // Ordered corridor stops (route milestones; falls back to the two + // endpoints) — lets the UI draw per-segment occupancy and label legs. + stops: this.mapScheduleStops(schedule), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -6824,6 +6891,42 @@ export class TrainSchedulingService { }; } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ + private mapScheduleStops( + schedule: TrainSchedule, + ): Array<{ yardId: string; label: string }> { + const milestones = [...(schedule.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const raw = milestones.length >= 2 + ? milestones.map((m) => ({ + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? m.yardId, + })) + : [ + { + yardId: schedule.originStationId, + label: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + schedule.originStationId, + }, + { + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + schedule.destinationStationId, + }, + ]; + const seen = new Set(); + return raw.filter((stop) => { + if (!stop.yardId || seen.has(stop.yardId)) return false; + seen.add(stop.yardId); + return true; + }); + } + private isHoldActive(booking: Booking): boolean { if (!booking.holdExpiresAt) return false; return booking.holdExpiresAt.getTime() > Date.now(); @@ -7232,6 +7335,232 @@ export class TrainSchedulingService { return { id: itemId, containerNumber: dto.containerNumber ?? null }; } + /** + * Staff rearrange: move one container to another wagon of the same train, or + * swap two containers (cross-wagon, or same-wagon to exchange slot positions). + * Capacity is re-validated here — one wagon holds 2 TEU (one 40ft or two + * 20ft) and the wagon's rated payload is never exceeded — so a drag on the + * consist can't silently overload a wagon. Allocation rows follow the items: + * the booking gets an allocation on the target wagon (created if missing), + * weights shift with the container, and an allocation left with no items is + * deleted. + */ + async moveContainerItem( + scheduleId: string, + itemId: string, + dto: MoveContainerItemDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) { + throw new BadRequestException('Cannot rearrange containers on a dispatched train'); + } + + const wagonById = new Map((schedule.trainSet?.wagons ?? []).map((w) => [w.id, w])); + const itemRepo = this.dataSource.getRepository(WagonAllocationContainerItem); + const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + + const item = await itemRepo.findOne({ + where: { id: itemId }, + relations: { allocation: true, containerType: true }, + }); + const sourceWagon = item?.allocation + ? wagonById.get(item.allocation.trainSetWagonId) + : undefined; + if (!item?.allocation || !sourceWagon) { + throw new NotFoundException(`Container item ${itemId} not found on this schedule`); + } + const targetWagon = wagonById.get(dto.targetTrainSetWagonId); + if (!targetWagon) { + throw new NotFoundException('Target wagon is not part of this schedule'); + } + + const loadAllocations = (trainSetWagonId: string) => + allocRepo.find({ + where: { trainSetWagonId }, + relations: { containerItems: { containerType: true } }, + }); + const [sourceAllocs, targetAllocs] = await Promise.all([ + loadAllocations(sourceWagon.id), + loadAllocations(targetWagon.id), + ]); + if ( + targetAllocs.some((a) => (a.loadType ?? '').toUpperCase().includes('BULK')) + ) { + throw new BadRequestException( + `Wagon #${targetWagon.sequenceNo} carries a bulk load — containers cannot ride it`, + ); + } + + const swapItem = dto.swapWithItemId + ? targetAllocs + .flatMap((a) => a.containerItems ?? []) + .find((it) => it.id === dto.swapWithItemId) + : undefined; + if (dto.swapWithItemId && !swapItem) { + throw new BadRequestException('The container to swap with is not on the target wagon'); + } + if (swapItem?.id === item.id) { + throw new BadRequestException('Cannot swap a container with itself'); + } + if (sourceWagon.id === targetWagon.id && !swapItem) { + return this.getTrainScheduleById(scheduleId); + } + + // TEU per container: 40ft fills a wagon (2), 20ft takes half (1). One + // wagon never exceeds 2 TEU — the same rule the auto-allocation packs by. + const MAX_TEU_PER_WAGON = 2; + const teuOf = (it: { containerType?: { sizeFt?: number | null } | null }) => + (it.containerType?.sizeFt ?? 20) >= 40 ? 2 : 1; + const itemsOf = (allocs: WagonBookingAllocation[]) => + allocs.flatMap((a) => a.containerItems ?? []); + // Weight a container carries into the move: its own gross when recorded, + // otherwise an even share of its allocation's weight. + const weightOf = ( + it: WagonAllocationContainerItem, + alloc: WagonBookingAllocation, + siblings: number, + ) => + Number(it.grossWeightTons) || + Number(alloc.allocatedWeightTons) / Math.max(1, siblings); + + const sourceAlloc = sourceAllocs.find((a) => a.id === item.wagonBookingAllocationId); + if (!sourceAlloc) { + throw new NotFoundException(`Container item ${itemId} not found on this schedule`); + } + const itemWeight = weightOf(item, sourceAlloc, (sourceAlloc.containerItems ?? []).length); + const swapAlloc = swapItem + ? targetAllocs.find((a) => a.id === swapItem.wagonBookingAllocationId) + : undefined; + const swapWeight = + swapItem && swapAlloc + ? weightOf(swapItem, swapAlloc, (swapAlloc.containerItems ?? []).length) + : 0; + + if (sourceWagon.id !== targetWagon.id) { + const targetTeu = itemsOf(targetAllocs) + .filter((it) => it.id !== swapItem?.id) + .reduce((sum, it) => sum + teuOf(it), 0); + if (targetTeu + teuOf(item) > MAX_TEU_PER_WAGON) { + throw new BadRequestException( + `Wagon #${targetWagon.sequenceNo} has no room — a wagon holds one 40ft or two 20ft containers`, + ); + } + if (swapItem) { + const sourceTeu = itemsOf(sourceAllocs) + .filter((it) => it.id !== item.id) + .reduce((sum, it) => sum + teuOf(it), 0); + if (sourceTeu + teuOf(swapItem) > MAX_TEU_PER_WAGON) { + throw new BadRequestException( + `Wagon #${sourceWagon.sequenceNo} has no room for the swapped container — a wagon holds one 40ft or two 20ft containers`, + ); + } + } + + const cargoOn = (allocs: WagonBookingAllocation[]) => + allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); + const checkPayload = ( + wagon: { sequenceNo: number; capacityTons?: number | null }, + cargoAfter: number, + ) => { + const capacity = Number(wagon.capacityTons ?? 0); + if (capacity > 0 && cargoAfter > capacity + 0.001) { + throw new BadRequestException( + `Wagon #${wagon.sequenceNo} would carry ${roundTons(cargoAfter)}T — over its ${capacity}T payload`, + ); + } + }; + checkPayload(targetWagon, cargoOn(targetAllocs) - swapWeight + itemWeight); + if (swapItem) { + checkPayload(sourceWagon, cargoOn(sourceAllocs) - itemWeight + swapWeight); + } + } + + await this.dataSource.transaction(async (manager) => { + const items = manager.getRepository(WagonAllocationContainerItem); + const allocs = manager.getRepository(WagonBookingAllocation); + + // Same-wagon swap: the containers only trade slot positions. + if (sourceWagon.id === targetWagon.id && swapItem) { + const a = item.positionOnWagon ?? null; + const b = swapItem.positionOnWagon ?? null; + await items.update(item.id, { positionOnWagon: b }); + await items.update(swapItem.id, { positionOnWagon: a }); + return; + } + + const moveOne = async ( + moving: WagonAllocationContainerItem, + toWagonId: string, + weight: number, + ) => { + // Re-read the source allocation — the other leg of a swap may have + // already shifted weight on it within this transaction. + const from = await allocs.findOne({ + where: { id: moving.wagonBookingAllocationId }, + }); + if (!from) return; + let to = await allocs.findOne({ + where: { trainSetWagonId: toWagonId, bookingId: from.bookingId }, + }); + if (!to) { + to = await allocs.save( + allocs.create({ + trainSetWagonId: toWagonId, + bookingId: from.bookingId, + allocatedWeightTons: 0, + loadType: from.loadType ?? 'CONTAINER', + status: from.status ?? 'PLANNED', + }), + ); + } + await items.update(moving.id, { wagonBookingAllocationId: to.id }); + await allocs.update(to.id, { + allocatedWeightTons: roundTons(Number(to.allocatedWeightTons) + weight), + }); + const remaining = await items.count({ + where: { wagonBookingAllocationId: from.id }, + }); + if (remaining === 0) { + await allocs.delete(from.id); + } else { + await allocs.update(from.id, { + allocatedWeightTons: roundTons( + Math.max(0, Number(from.allocatedWeightTons) - weight), + ), + }); + } + }; + + await moveOne(item, targetWagon.id, itemWeight); + if (swapItem) { + await moveOne(swapItem, sourceWagon.id, swapWeight); + } + + // Keep slot positions dense (1..n) on both touched wagons. + const renumber = async (trainSetWagonId: string) => { + const wagonAllocs = await allocs.find({ + where: { trainSetWagonId }, + relations: { containerItems: true }, + }); + const wagonItems = wagonAllocs + .flatMap((a) => a.containerItems ?? []) + .sort((x, y) => (x.positionOnWagon ?? 99) - (y.positionOnWagon ?? 99)); + for (let i = 0; i < wagonItems.length; i += 1) { + if (wagonItems[i].positionOnWagon !== i + 1) { + await items.update(wagonItems[i].id, { positionOnWagon: i + 1 }); + } + } + }; + await renumber(sourceWagon.id); + await renumber(targetWagon.id); + }); + + return this.getTrainScheduleById(scheduleId); + } + async getUnassignedBookings(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 5870d3785..778dc70dd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => { expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']); }); }); + +describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => { + const allowed = { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }; + // Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1. + const legs = (entries: Array<[string, { from: number; to: number }]>) => + new Map(entries); + + it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.fitting.map((b) => b.id).sort()).toEqual([ + 'EXPORT-1', + 'INTERCITY-1', + ]); + // Two slots planned, but both drawn from the single physical wagon. + expect(result.plan).toHaveLength(2); + }); + + it('still defers when the legs overlap and stock is exhausted', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + // Both ride edge 0 — they compete for the one wagon. + ['EXPORT-1', { from: 0, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']); + expect(result.deferred).toHaveLength(1); + expect(result.deferred[0]!.reference).toBe('INTERCITY-1'); + expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); + }); + + it('never packs bookings with different legs into the same wagon slot', () => { + // Two 20ft units with room to share one wagon by TEU — but disjoint legs + // must open separate slots (each with its own leg), not one mixed slot. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 2]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.plan).toHaveLength(2); + const bookingsPerSlot = result.plan.map((s) => + [...new Set(s.allocations.map((a) => a.bookingId))].sort(), + ); + expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + }); + + it('behaves exactly like the whole-route planner when no legs are given', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + // One wagon, two 20ft bookings: they TEU-share the single slot (legacy). + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 6a3c1c49f..699d7a432 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -58,8 +58,18 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only + * share a slot when their legs are identical — mixing corridors in one slot + * would degrade it to a whole-route slot (see stampSlotLegs) and silently + * re-occupy edges the cargo never rides. + */ + legKey: string; }; +/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ +export type BookingLeg = { from: number; to: number }; + type PlacementProblem = { kind: 'config' | 'stock'; message: string; @@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS const shortageFor = ( booking: Booking, candidates: WagonType[], - remaining: Map, + availableOf: (wagonTypeId: string) => number, ): BookingWagonShortage => { const wagonsNeeded = booking.freightType === 'BULK' @@ -100,7 +110,7 @@ const shortageFor = ( ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( - (sum, wt) => sum + (remaining.get(wt.id) ?? 0), + (sum, wt) => sum + availableOf(wt.id), 0, ); return { @@ -140,14 +150,53 @@ export function planWagonsWithStock(params: { bookings: Booking[]; allowed: AllowedWagonTypeMap; stock: WagonStock; + /** + * Leg-aware stock: booking id → the stop-index range it rides. When given + * (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so + * the same physical wagon can serve an intercity booking on Gelan→Adama and + * an export booking on Adama→Doraleh — disjoint legs never compete for + * stock. Omitted → one edge, byte-identical to the old whole-route behavior. + */ + legs?: Map; + edgeCount?: number; }): FlexPlanResult { - const { bookings, allowed, stock } = params; - const remaining = new Map(stock.remainingByTypeId); + const { bookings, allowed, stock, legs } = params; + const edgeCount = Math.max(1, params.edgeCount ?? 1); const openSlots: OpenSlot[] = []; const fitting: Booking[] = []; const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + const legFor = (booking: Booking): BookingLeg => { + const leg = legs?.get(booking.id); + if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { + return { from: 0, to: edgeCount }; + } + return leg; + }; + const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`; + + // Wagons of a type in use per corridor edge. A type is available for a leg + // when its busiest edge WITHIN that leg still has stock spare — the max over + // edges is the number of physical wagons the type needs simultaneously. + const usedPerEdge = new Map(); + const usedRow = (wagonTypeId: string): number[] => { + let row = usedPerEdge.get(wagonTypeId); + if (!row) { + row = new Array(edgeCount).fill(0); + usedPerEdge.set(wagonTypeId, row); + } + return row; + }; + const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { + const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0; + const row = usedPerEdge.get(wagonTypeId); + if (!row) return total; + let busiest = 0; + for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0); + return total - busiest; + }; + const noStockMessage = (candidates: WagonType[]): string => { const codes = candidates.map((wt) => wt.code).join('/'); return stock.mode === 'TRAIN' @@ -155,13 +204,14 @@ export function planWagonsWithStock(params: { : `No available ${codes} wagon at the yard`; }; - /** Open a new wagon of one of the candidate types, consuming stock. */ + /** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */ const openSlot = ( candidates: WagonType[], kind: SlotLoadType, cargoTypeId: string | null, + leg: BookingLeg, ): OpenSlot | PlacementProblem => { - const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); + const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates), candidates }; } @@ -170,22 +220,26 @@ export function planWagonsWithStock(params: { const chosen = [...inStock].sort((a, b) => kind === 'BULK' ? Number(b.capacityTons) - Number(a.capacityTons) || - (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0) - : (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0), + availableFor(b.id, leg) - availableFor(a.id, leg) + : availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; - remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1); + const row = usedRow(chosen.id); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), teuUsed: 0, kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), + legKey: legKeyOf(leg), }; openSlots.push(open); return open; }; const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { + const leg = legFor(booking); + const legKey = legKeyOf(leg); if (booking.freightType === 'CONTAINER') { const units = expandBookingContainerUnits([booking]); if (!units.length) { @@ -209,11 +263,12 @@ export function planWagonsWithStock(params: { let target = openSlots.find( (open) => open.kind === 'CONTAINER' && + open.legKey === legKey && allowedIds.has(open.slot.wagonTypeId) && open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, ); if (!target) { - const openedSlot = openSlot(candidates, 'CONTAINER', null); + const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; } @@ -246,6 +301,7 @@ export function planWagonsWithStock(params: { for (const open of openSlots) { if (remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; + if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; @@ -263,7 +319,7 @@ export function planWagonsWithStock(params: { } while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId); + const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); if ('message' in openedSlot) return openedSlot; const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); addAllocation( @@ -282,7 +338,9 @@ export function planWagonsWithStock(params: { for (const booking of sortBookingsForScheduling(bookings)) { // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. - const remainingSnapshot = new Map(remaining); + const usedSnapshot = new Map( + [...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]), + ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ teuUsed: open.teuUsed, @@ -299,8 +357,8 @@ export function planWagonsWithStock(params: { } // Roll back this booking's partial placements. - remaining.clear(); - for (const [key, value] of remainingSnapshot) remaining.set(key, value); + usedPerEdge.clear(); + for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value); openSlots.length = slotCountSnapshot; openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; @@ -315,11 +373,14 @@ export function planWagonsWithStock(params: { }); if (problem.kind === 'config') configIssues.add(problem.message); - // remaining is rolled back here, so the shortage counts the stock this + // Usage is rolled back here, so the shortage counts the stock this // booking actually saw — not what its own partial placement consumed. + const bookingLeg = legFor(booking); const shortage = problem.kind === 'stock' && problem.candidates?.length - ? shortageFor(booking, problem.candidates, remaining) + ? shortageFor(booking, problem.candidates, (wagonTypeId) => + Math.max(0, availableFor(wagonTypeId, bookingLeg)), + ) : null; deferred.push({ id: booking.id, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index bb5ce890c..1aa555531 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -525,6 +525,40 @@ export function validateMixedTrainLimits( ); } +/** + * Leg-aware limit check: with a real stop list, a slot only counts on the + * edges it actually rides (boardYardId→alightYardId; null = the schedule's + * own endpoint). Each edge is validated as its own consist, so an intercity + * wagon on Gelan→Adama never counts against a train that is full only on + * Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check. + */ +export function validateMixedTrainLimitsPerEdge( + wagonPlan: WagonPlanSlot[], + wagonTypes: Array>, + limits: TrainLimitConfig | undefined, + stops: string[], +): string[] { + if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); + const lastIdx = stops.length - 1; + const spans = wagonPlan.map((slot) => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; + // A yard missing from the stop list keeps the slot on the whole route. + return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; + }); + const violations = new Set(); + for (let edge = 0; edge < lastIdx; edge += 1) { + const active = wagonPlan.filter( + (_, i) => spans[i].from <= edge && edge < spans[i].to, + ); + if (!active.length) continue; + for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { + violations.add(violation); + } + } + return [...violations]; +} + export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx index 35a75d66c..088f65ba9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx @@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [ { value: "CONTAINER_OPENED", label: "Container opened" }, { value: "CONTAINER_DAMAGED", label: "Container damaged" }, { value: "FLUID_LEAKING", label: "Fluid leaking" }, + { value: "OTHER", label: "Other" }, ]; const LABEL: Record = { @@ -30,6 +31,7 @@ const LABEL: Record = { CONTAINER_OPENED: "Container opened", CONTAINER_DAMAGED: "Container damaged", FLUID_LEAKING: "Fluid leaking", + OTHER: "Other", }; export function IncidentReportCard({ bookingId }: { bookingId: string }) { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx index e4512e42a..1f872d1bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx @@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) { * confirmed manually when the train is physically at the booking's origin / * destination yard (the server validates against recorded checkpoints). */ +/** Plain-language journey states for the accepted ride-along table. */ +const INTERCITY_STATUS_META: Record = { + SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" }, + APPROVED: { label: "Ready to load (gov)", color: "edr-green" }, + PAID: { label: "Paid — ready to load", color: "edr-green" }, + IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" }, + COMPLETED: { label: "Delivered", color: "teal" }, +}; + export function IntercityRideAlongPanel({ scheduleId, direction, @@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({ }), ); - const invalidate = () => - queryClient.invalidateQueries({ + // Accepting/loading/unloading a ride-along changes the schedule's booking + // list, the yard worklists AND this panel — refresh all three so the + // workspace board and yard-work tables never show a stale picture. + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }), }); + void queryClient.invalidateQueries({ + queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), + }); + void queryClient.invalidateQueries({ + queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }), + }); + }; const accept = useMutation( api.trainScheduling.acceptIntercityBookings.mutationOptions({ @@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({ - - {row.status} + + {INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status} 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 c7fc49b18..02475214e 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({ customer={b.customer} weightTons={b.weightTons} status={b.status} + intercity={b.tradeDirection === "DOMESTIC"} + leg={ + b.origin && + b.destination && + (b.originYardId !== schedule.originStation?.id || + b.destinationYardId !== schedule.destinationStation?.id) + ? `${b.origin} → ${b.destination}` + : null + } loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined} right={ canManage ? ( @@ -840,6 +849,8 @@ function BookingCard({ status, loadingStatus, waitingForWagon, + intercity, + leg, right, }: { reference: string; @@ -849,6 +860,10 @@ function BookingCard({ loadingStatus?: "LOADED" | "UNLOADED"; /** Paid, but no wagon of the required type was free — waiting for one. */ waitingForWagon?: boolean; + /** DOMESTIC ride-along riding only part of this train's corridor. */ + intercity?: boolean; + /** "Origin → Destination" when the booking rides a sub-corridor leg. */ + leg?: string | null; right?: React.ReactNode; }) { return ( @@ -874,6 +889,16 @@ function BookingCard({ {reference} {status ? : null} + {intercity ? ( + + + Intercity + + + ) : null} {waitingForWagon ? ( ) : null} + {leg ? ( + + {leg} + + ) : null} {right ? {right} : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx index e5b63ea24..73297884d 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx @@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from import { Box, Package } from "lucide-react"; import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling"; -type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | { +type WagonSlot = (WagonPlanRow & { + physicalWagonNumber?: string | null; + tareWeightTons?: number | null; +}) | { sequenceNo: number; capacityTons: number; assignedWeightTons: number; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx index 76ccdfde2..4c649edf8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx @@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) { }), ); - const invalidate = () => - queryClient.invalidateQueries({ + // Loading/unloading changes booking status on the schedule detail and the + // intercity panel too — refresh all three so no surface shows a stale state. + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), }); + void queryClient.invalidateQueries({ + queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }), + }); + void queryClient.invalidateQueries({ + queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }), + }); + }; const load = useMutation( api.trainScheduling.loadScheduleBooking.mutationOptions({ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 8ac374c1a..de74db9a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core"; import { Building2, @@ -14,6 +15,15 @@ import { freightBrand } from "@/theme/freight-brand"; type Wagon = NonNullable["wagons"][number]; type Locomotive = NonNullable["locomotive"]; +export interface ContainerMove { + itemId: string; + targetWagonId: string; + /** Present when the drop landed on another container — swap the two. */ + swapWithItemId?: string; +} + +type DragState = { itemId: string; sourceWagonId: string } | null; + interface InteractiveTrainConsistProps { wagons: Wagon[]; locomotive: Locomotive | null | undefined; @@ -23,8 +33,16 @@ interface InteractiveTrainConsistProps { onSelectWagon: (wagon: Wagon) => void; /** Booking id to highlight across the train (e.g. selected in the side panel). */ highlightBookingId?: string | null; + /** Containers become draggable between wagons (drop on a container = swap). */ + canRearrange?: boolean; + onMoveContainer?: (move: ContainerMove) => void; } +const wagonItems = (wagon: Wagon) => + (wagon.allocations ?? []) + .flatMap((a) => a.containerItems ?? []) + .sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99)); + const CONTAINER_GRADIENTS = [ "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", @@ -151,16 +169,27 @@ function WagonCar({ selected, highlighted, onSelect, + drag, + onDragChange, + onMoveContainer, + canRearrange, }: { wagon: Wagon; company: string | null; selected: boolean; highlighted: boolean; onSelect: () => void; + drag: DragState; + onDragChange: (drag: DragState) => void; + onMoveContainer?: (move: ContainerMove) => void; + canRearrange: boolean; }) { + const [dropHover, setDropHover] = useState(false); const allocation = wagon.allocations?.[0]; const isEmpty = !allocation; - const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); + const isBulk = (wagon.allocations ?? []).some((a) => + (a.loadType ?? "").toUpperCase().includes("BULK"), + ); // GROSS on both sides: cargo + tare vs rated payload + tare. const tare = wagon.tareWeightTons ?? 0; const assigned = @@ -170,10 +199,19 @@ function WagonCar({ const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; - const containerNumbers = (allocation?.containerItems ?? []).map( - (c) => c.containerNumber?.trim() || "—", + const items = wagonItems(wagon); + const blocks = items.slice(0, 2); + const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—"); + + // Where a dragged container may land: another wagon, not bulk-loaded, with a + // free half (the API re-checks TEU/weight — this only paints the hint). + const dropEligible = Boolean( + drag && drag.sourceWagonId !== wagon.id && !isBulk && items.length < 2, ); - const blocks = containerNumbers.slice(0, 2); + const endDrag = () => { + onDragChange(null); + setDropHover(false); + }; const ringColor = selected ? freightBrand.primary @@ -189,6 +227,21 @@ function WagonCar({ style={{ width: 120, flexShrink: 0, cursor: "pointer" }} > { + if (dropEligible) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDropHover(true); + } + }} + onDragLeave={() => setDropHover(false)} + onDrop={(e) => { + if (dropEligible && drag) { + e.preventDefault(); + onMoveContainer?.({ itemId: drag.itemId, targetWagonId: wagon.id }); + } + endDrag(); + }} style={{ position: "relative", height: 70, @@ -205,10 +258,16 @@ function WagonCar({ : isEmpty ? "none" : "0 3px 10px rgba(15,41,27,0.08)", + outline: dropHover + ? "2px solid var(--mantine-color-cyan-6)" + : dropEligible + ? "2px dashed var(--mantine-color-cyan-4)" + : "none", + outlineOffset: 2, overflow: "hidden", display: "flex", flexDirection: "column", - transition: "box-shadow 120ms ease", + transition: "box-shadow 120ms ease, outline-color 120ms ease", }} > {/* top accent strip */} @@ -274,28 +333,84 @@ function WagonCar({ ) : ( - {(blocks.length ? blocks : ["—"]).map((cn, i) => ( + {blocks.length ? ( + blocks.map((item, i) => { + const isDragged = drag?.itemId === item.id; + const swapEligible = Boolean(drag && drag.itemId !== item.id); + return ( + { + e.stopPropagation(); + e.dataTransfer.effectAllowed = "move"; + // Firefox needs data set for the drag to start. + e.dataTransfer.setData("text/plain", item.id); + onDragChange({ itemId: item.id, sourceWagonId: wagon.id }); + }} + onDragEnd={endDrag} + onDragOver={(e) => { + if (swapEligible) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + } + }} + onDrop={(e) => { + if (swapEligible && drag) { + e.preventDefault(); + e.stopPropagation(); + onMoveContainer?.({ + itemId: drag.itemId, + targetWagonId: wagon.id, + swapWithItemId: item.id, + }); + } + endDrag(); + }} + style={{ + flex: 1, + minWidth: 0, + height: 26, + borderRadius: 4, + background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length], + border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`, + boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "0 2px", + cursor: canRearrange ? "grab" : undefined, + opacity: isDragged ? 0.35 : 1, + transition: "opacity 120ms ease", + }} + > + + {item.containerNumber?.trim() || "—"} + + + ); + }) + ) : ( - - {cn} + + — - ))} + )} )} @@ -447,7 +562,10 @@ export const InteractiveTrainConsist = ({ selectedWagonId, onSelectWagon, highlightBookingId, + canRearrange = false, + onMoveContainer, }: InteractiveTrainConsistProps) => { + const [drag, setDrag] = useState(null); return ( onSelectWagon(wagon)} + drag={drag} + onDragChange={setDrag} + onMoveContainer={onMoveContainer} + canRearrange={canRearrange} /> ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index e5b12fe1c..80120d075 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -1,13 +1,15 @@ import { useMemo, useState } from "react"; import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core"; -import { MousePointerClick, TrainFront } from "lucide-react"; +import { isAxiosError } from "axios"; +import { Hand, MousePointerClick, TrainFront } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { TrainStatsBar } from "./TrainStatsBar"; import { WagonCard } from "./WagonCard"; -import { InteractiveTrainConsist } from "./InteractiveTrainConsist"; +import { InteractiveTrainConsist, type ContainerMove } from "./InteractiveTrainConsist"; import { RemoveBookingModal } from "./RemoveBookingModal"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; import { freightBrand } from "@/theme/freight-brand"; type Wagon = NonNullable["wagons"][number]; @@ -47,6 +49,7 @@ export const TrainConsistView = ({ }: TrainConsistViewProps) => { const [selectedWagonId, setSelectedWagonId] = useState(null); const [removeModalOpen, setRemoveModalOpen] = useState(false); + const { toast } = useToast(); const unassignMutation = useMutation( api.trainScheduling.unassignBooking.mutationOptions(), @@ -54,9 +57,37 @@ export const TrainConsistView = ({ const removeWagonMutation = useMutation( api.trainScheduling.removeWagonSlot.mutationOptions(), ); + const moveContainerMutation = useMutation( + api.trainScheduling.moveContainerItem.mutationOptions(), + ); const trainSet = scheduleDetail.trainSet; const wagons = trainSet?.wagons ?? []; + const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status); + + const handleMoveContainer = async (move: ContainerMove) => { + if (moveContainerMutation.isPending) return; + try { + await moveContainerMutation.mutateAsync({ + scheduleId, + itemId: move.itemId, + targetTrainSetWagonId: move.targetWagonId, + swapWithItemId: move.swapWithItemId, + }); + toast({ title: move.swapWithItemId ? "Containers swapped" : "Container moved" }); + } catch (error) { + const message = isAxiosError(error) + ? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null) + : null; + toast({ + title: "Could not move container", + description: Array.isArray(message) + ? message.join(", ") + : (message ?? "The move was rejected — check the wagon's space and load."), + variant: "destructive", + }); + } + }; // Join company/customer name from schedule bookings by booking id. const companyByBooking = useMemo(() => { @@ -152,13 +183,21 @@ export const TrainConsistView = ({ + {canRearrange ? ( + + + + Drag a container to move it — drop on a container to swap + + + ) : null} - + setSelectedWagonId((prev) => (prev === w.id ? null : w.id))} highlightBookingId={highlightBookingId} + canRearrange={canRearrange && !moveContainerMutation.isPending} + onMoveContainer={(move) => void handleMoveContainer(move)} /> @@ -178,7 +219,7 @@ export const TrainConsistView = ({ Editing wagon #{selectedWagon.sequenceNo} - Update container numbers or remove the booking + Update container numbers, move containers to another wagon, or remove the booking void handleMoveContainer(move) : undefined} /> ) : wagons.length ? ( @@ -209,7 +252,8 @@ export const TrainConsistView = ({ - Click a wagon in the train to edit container numbers or remove its booking. + Click a wagon to edit its containers — or drag a container between wagons to + rearrange the load. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index 915cfa629..541e01a09 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -1,5 +1,19 @@ -import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core"; import { + ActionIcon, + Badge, + Box, + Button, + Card, + Group, + Menu, + Progress, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + ArrowLeftRight, Building2, Container as ContainerIcon, Fuel, @@ -10,6 +24,7 @@ import { } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { ContainerNumberInput } from "./ContainerNumberInput"; +import type { ContainerMove } from "./InteractiveTrainConsist"; import { freightBrand } from "@/theme/freight-brand"; type Wagon = NonNullable["wagons"][number]; @@ -21,8 +36,17 @@ interface WagonCardProps { scheduleStatus?: string; onRemoveBooking: (wagon: Wagon) => void; onRemoveWagon: (wagonId: string) => void; + /** All wagons of the consist — targets for the per-container move menu. */ + wagons?: Wagon[]; + onMoveContainer?: (move: ContainerMove) => void; } +const itemCountOf = (w: Wagon) => + (w.allocations ?? []).reduce((sum, a) => sum + (a.containerItems?.length ?? 0), 0); + +const isBulkWagon = (w: Wagon) => + (w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK")); + export const WagonCard = ({ wagon, company, @@ -30,6 +54,8 @@ export const WagonCard = ({ scheduleStatus, onRemoveBooking, onRemoveWagon, + wagons, + onMoveContainer, }: WagonCardProps) => { const isDispatched = scheduleStatus === "DISPATCHED"; const allocation = wagon.allocations?.[0]; @@ -108,20 +134,67 @@ export const WagonCard = ({ Containers - {allocation.containerItems.map((item, idx) => ( - - - - #{idx + 1} - - - - ))} + {allocation.containerItems.map((item, idx) => { + const targets = (wagons ?? []).filter( + (w) => w.id !== wagon.id && !isBulkWagon(w) && itemCountOf(w) < 2, + ); + return ( + + + + #{idx + 1} + + + {!isDispatched && onMoveContainer ? ( + + + + + + + + + + Move to wagon + {targets.length ? ( + targets.map((w) => { + const count = itemCountOf(w); + return ( + + onMoveContainer({ + itemId: item.id, + targetWagonId: w.id, + }) + } + > + + + #{w.sequenceNo} ·{" "} + {w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"} + + + {count ? `${count}/2` : "empty"} + + + + ); + }) + ) : ( + No wagon has free space + )} + + + ) : null} + + ); + })} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx index a495a5529..f6bc9b387 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx @@ -280,3 +280,135 @@ export function RouteCorridor({ ); } + +/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */ +export type SegmentStripBooking = { + originYardId?: string | null; + destinationYardId?: string | null; + tradeDirection?: string | null; + wagonsRequired?: number | null; +}; + +/** + * Per-segment wagon occupancy along the corridor: which legs are full and + * which still run empty. Through cargo (unknown/off-route yards) occupies the + * whole corridor; a ride-along counts only on its own leg — this is what makes + * "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a + * glance instead of two disconnected booking lists. + */ +export function SegmentOccupancyStrip({ + stops, + bookings, + maxWagons, +}: { + stops: Array<{ yardId: string; label: string }>; + bookings: SegmentStripBooking[]; + maxWagons?: number | null; +}) { + if (stops.length < 2) return null; + const lastIdx = stops.length - 1; + const indexOf = new Map(stops.map((s, i) => [s.yardId, i])); + + const segments = stops.slice(0, -1).map((stop, edge) => { + let cargo = 0; + let intercity = 0; + for (const b of bookings) { + const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0; + const to = + (b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ?? + lastIdx; + const rides = from <= edge && edge < (to > from ? to : lastIdx); + if (!rides) continue; + const wagons = Number(b.wagonsRequired) || 1; + if (b.tradeDirection === "DOMESTIC") intercity += wagons; + else cargo += wagons; + } + return { from: stop, to: stops[edge + 1], cargo, intercity }; + }); + + const cap = Number(maxWagons) || null; + + return ( + + {segments.map((seg, i) => { + const used = seg.cargo + seg.intercity; + const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null; + const full = cap != null && used >= cap; + return ( + + + + + {seg.from.label} + + + + + {used} + {cap ? `/${cap}` : ""} wagons + {full ? " · full" : ""} + + + {cap ? ( + <> + + + + ) : ( + + )} + + + {seg.cargo} cargo + {seg.intercity > 0 ? ( + + {" "} + · {seg.intercity} intercity + + ) : null} + + + {i === segments.length - 1 ? ( + + + + {seg.to.label} + + + ) : null} + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ce89774d5..6a1ce5c9e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -399,6 +399,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`, UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`, + MOVE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => + `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}/move`, UNASSIGNED_BOOKINGS: (scheduleId: string) => `/train-scheduling/schedules/${scheduleId}/unassigned-bookings`, COMPOSITION_REMOVALS: (scheduleId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index af045ed5c..108e4f461 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -17,6 +17,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, ClipboardList, FileText, Upload, @@ -37,6 +38,8 @@ import { type GlClearanceUploadKind, } from "@/components/contracts/GlClearanceUploadModal"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; import { useFileViewer } from "@/hooks/useFileViewer"; import { useBookingMilestones } from "@/hooks/contracts/useContracts"; @@ -147,6 +150,9 @@ export default function GlClearanceDetailPage() { "vesselDepartureDate" in data.clearance ? (data.clearance.vesselDepartureDate ?? null) : null; + // Incident reporting attaches to a booking; a contract-level clearance can + // only report against its linked booking once one exists. + const incidentBookingId = data.kind === "booking" ? id : linkedBookingId; // The shipment booking instance backing this clearance (per-booking GENERAL // customs). Bare until GL completes it: no cargo, no price. @@ -221,6 +227,11 @@ export default function GlClearanceDetailPage() { > Customs documents (all steps) + {incidentBookingId ? ( + }> + Incidents + + ) : null} @@ -310,6 +321,19 @@ export default function GlClearanceDetailPage() { )} + + {incidentBookingId ? ( + + + + + Log container or seal issues discovered during clearance handling. + + + + + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 58d027c8c..1e3735961 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -866,7 +866,7 @@ export default function BatchScheduleDetailPage() { > Refresh - {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? ( + {/* {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? ( - ) : null} + ) : null} */} {data.windowPhase === "DOC_REVIEW" ? (