diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d23d5bd2a..a26f11bdf 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,7 @@ import { EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; @@ -157,6 +158,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ], providers: [ EdrOrgSeeder, + FreightPositionsSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, PricingDataSeeder, @@ -180,6 +182,7 @@ export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, private readonly pricingDataSeeder: PricingDataSeeder, @@ -201,6 +204,7 @@ export class AppModule implements OnApplicationBootstrap { await this.freightPermissionKeyMigrationSeeder.run(); await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.freightPositionsSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); await this.pricingDataSeeder.run(); diff --git a/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts b/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts new file mode 100644 index 000000000..ca88ef7cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Segment corridor bookings: a booking may ride only part of a train's route + * (its own origin→destination leg), so dispatch/arrival become per-booking + * facts and wagon capacity is consumed per leg instead of per whole route. + * + * - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at + * the booking's origin yard and unload at its destination yard. Clearance + * gates read arrived_at, not the train's actual_arrival_at. + * - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot + * occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist + * without consuming each other's capacity. + * - wagon_movements: auditable ledger of every physical wagon relocation + * (loaded leg / empty reposition / manual correction) with the acting user. + */ +export class SegmentCorridorBookings1990000000000 implements MigrationInterface { + name = 'SegmentCorridorBookings1990000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS loaded_at timestamptz, + ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid, + ADD COLUMN IF NOT EXISTS arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_movements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE, + from_yard_id uuid REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL, + booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL, + kind varchar(30) NOT NULL, + moved_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS board_yard_id, + DROP COLUMN IF EXISTS alight_yard_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS loaded_at, + DROP COLUMN IF EXISTS loaded_by_user_id, + DROP COLUMN IF EXISTS arrived_at, + DROP COLUMN IF EXISTS arrived_by_user_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index 2140a7688..9ef951853 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PAID'], + statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index ac6e35b56..d93b5b7bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -102,6 +102,7 @@ export function computeNextStep( description: 'Mark shipment as in transit', }; case 'IN_TRANSIT': + case 'ARRIVED': return { action: 'COMPLETE', description: 'Mark shipment complete', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index b5c277073..8b6e8a2f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -485,7 +485,7 @@ export class BookingTransitionService { async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["IN_TRANSIT"]); + assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); const updated = await this.bookingsRepository.update(bookingId, { status: "COMPLETED", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ccdab3379..15c8ef19d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1019,6 +1019,44 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose + * origin AND destination both lie on the day's corridor stop set — covers + * full-route bookings and sub-corridor bookings (Dire→Djibouti on an + * Addis→…→Djibouti train). The caller still verifies stop ORDER per train + * via the corridor budget; this query only narrows the pool. Same status + * rules and ordering as {@link findBatchPool}. + */ + findBatchPoolByCorridorDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 1fb37dc31..6dc6211f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -605,10 +605,15 @@ export class BookingsService { if (schedule.bookingWindowStatus !== 'OPEN') { throw new BadRequestException('Selected schedule is no longer accepting bookings'); } - if ( - schedule.originStationId !== dto.originYardId || - schedule.destinationStationId !== dto.destinationYardId - ) { + // Corridor-aware: the booking's leg must lie on the schedule's route in + // stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti + // train) are valid. + const stops = await this.trainSchedulingService.stopYardsForSchedule( + schedule, + ); + const fromIdx = stops.indexOf(dto.originYardId); + const toIdx = stops.indexOf(dto.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException('Selected schedule is not on the booking route'); } } else if (dto.scheduledDate) { @@ -1278,6 +1283,14 @@ export class BookingsService { ): Promise { const booking = await this.findById(bookingId); + const journey = { + bookingStatus: booking.status ?? null, + bookingOriginYardId: booking.originYardId ?? null, + bookingDestinationYardId: booking.destinationYardId ?? null, + loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null, + arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null, + }; + const empty: Freight.IBookingTracking = { bookingId: booking.id, bookingReference: booking.reference, @@ -1295,6 +1308,7 @@ export class BookingsService { actualArrivalAt: null, scheduledDepartureAt: null, scheduledArrivalAt: null, + ...journey, }; if (!booking.trainScheduleId) { @@ -1331,6 +1345,7 @@ export class BookingsService { actualArrivalAt: track.actualArrivalAt, scheduledDepartureAt: track.scheduledDepartureAt, scheduledArrivalAt: track.scheduledArrivalAt, + ...journey, }; } @@ -1607,7 +1622,7 @@ export class BookingsService { if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } - const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED']; + const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index cf0ca76f6..a2f369426 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [ 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', + 'ARRIVED', 'COMPLETED', 'REJECTED', 'CANCELLED', @@ -458,6 +459,24 @@ export class Booking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; + // ── Per-booking journey (segment corridor bookings) ──────────────────────── + // A booking rides only its own origin→destination leg of the train's route, + // so dispatch/arrival are per-booking facts, not train facts. Clearance gates + // read arrivedAt (booking arrival), never the schedule's actualArrivalAt. + /** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true }) + loadedByUserId?: string | null; + + /** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */ + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true }) + arrivedByUserId?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ @Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true }) paymentDeadline?: Date | null; diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 842a36616..aa9a31f49 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [ 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', + 'ARRIVED', 'COMPLETED', 'DELIVERED', 'CONSOLIDATED', diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 28a31c331..106acdb0b 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -202,15 +202,22 @@ export class GlOperationsService { .findOne({ where: { id: booking.trainScheduleId } }); } + // Per-booking journey first: a booking rides only its own leg, so ITS + // loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that + // unloaded at its own destination clears while the train keeps rolling, + // and a booking still on board does NOT clear just because the train + // arrived. The schedule actuals remain only as fallback for legacy + // in-flight bookings that predate per-booking load/unload (no loadedAt). + const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null; + const arrivedAt = + booking.arrivedAt ?? + (booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null)); + return { scheduleId: schedule?.id ?? null, wagonAllocated, - departedAt: schedule?.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - arrivedAt: schedule?.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, }; } @@ -278,7 +285,7 @@ export class GlOperationsService { /** * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * is secured on the train schedule (which itself follows wagon allocation). - * Replaces the previous batch; locked once the train departs or T1 is closed. + * Replaces the previous batch; locked only once GL Ethiopia closes the T1. */ async uploadT1Documents( bookingId: string, @@ -304,11 +311,8 @@ export class GlOperationsService { if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } - if (state.trainDepartedAt) { - throw new BadRequestException( - 'The train has departed — T1 transport documents can no longer be changed.', - ); - } + // Departure no longer locks T1 docs — GL DJ may replace them any time until + // GL Ethiopia closes/accepts the T1. await persistT1TransportUploads(this.filesService, bookingId, files); return { uploaded: files.length }; diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 2e8682951..aa1c956e0 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -276,6 +276,7 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ 'OPERATION_CHANGES_REQUESTED', 'ROAD_DISPATCH_PENDING', 'IN_TRANSIT', + 'ARRIVED', 'PAID', 'COMPLETED', 'CONTRACT_ACTIVE', 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 8f11cfc95..bdb8d8341 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 @@ -21,6 +21,7 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; findBatchPoolByRouteDay: jest.Mock; + findBatchPoolByCorridorDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -53,6 +54,7 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), + findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -196,6 +198,8 @@ describe('BookingBatchService — PAID reconcile', () => { cargoTotalWeightVgm: 10, freightType: 'CONTAINER', bookingContainers: [], + originYardId, + destinationYardId, }) as unknown as Booking; beforeEach(() => { @@ -227,13 +231,15 @@ describe('BookingBatchService — PAID reconcile', () => { trainSetId: `set-${id}`, trainSet: { locomotive: smallLoco }, scheduleBookings: [], + originStationId: originYardId, + destinationStationId: destinationYardId, }), ); }); it('spills overflow to the next train by priority, then reports unplaced', async () => { // 3 commercial bookings, descending priority; only 1 fits per train (2 total). - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ commercial('hi', 30), commercial('mid', 20), commercial('lo', 10), @@ -241,9 +247,8 @@ describe('BookingBatchService — PAID reconcile', () => { const touched = await service.fillRouteDay(originYardId, destinationYardId, day); - expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( - originYardId, - destinationYardId, + expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith( + [originYardId, destinationYardId], day, ); // Both trains were processed. @@ -258,7 +263,7 @@ describe('BookingBatchService — PAID reconcile', () => { }); it('reserves the chosen train id on each commercial booking', async () => { - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]); await service.fillRouteDay(originYardId, destinationYardId, day); @@ -286,9 +291,11 @@ describe('BookingBatchService — PAID reconcile', () => { freightType: 'CONTAINER', consolidationPartnerId: partnerId, bookingContainers: [{ quantity: 1 }], + originYardId, + destinationYardId, }) as unknown as Booking; - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ consol('a', 'b', 30), consol('b', 'a', 20), ]); @@ -313,9 +320,11 @@ describe('BookingBatchService — PAID reconcile', () => { freightType: 'CONTAINER', consolidationPartnerId: 'missing-partner', bookingContainers: [{ quantity: 1 }], + originYardId, + destinationYardId, } as unknown as Booking; - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]); await service.fillRouteDay(originYardId, destinationYardId, day); 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 e2e339e9b..a7957f378 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 @@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -42,13 +43,14 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; +import { + Capacity, + CorridorBudget, + CorridorLeg, + stopYardsFor, +} from './corridor-capacity.util'; -/** A train's remaining capacity along the three physical limits the batch enforces. */ -export interface Capacity { - wagons: number; - weightTons: number; - lengthMeters: number; -} +export type { Capacity } from './corridor-capacity.util'; /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { @@ -459,18 +461,14 @@ export class BookingBatchService implements OnModuleInit { throw new BadRequestException('Booking has no scheduled date'); } const day = eatDay(new Date(booking.scheduledDate)); + // Corridor-aware: any train whose route carries the booking's origin + // strictly before its destination qualifies — a Dire→Djibouti booking may + // ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the + // stop order, so we fetch the day's open trains without endpoint filters. const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { - originStationId: booking.originYardId, - destinationStationId: booking.destinationYardId, - status: TrainScheduleStatusEnum.Draft, - }, - { - originStationId: booking.originYardId, - destinationStationId: booking.destinationYardId, - status: TrainScheduleStatusEnum.Scheduled, - }, + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, ], }); const candidates = corridor @@ -493,6 +491,7 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); const required = need ?? this.needFor(booking, wagonLengths); + let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, @@ -500,8 +499,16 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); - if (this.fits(required, budget)) return schedule.id; + const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + corridorMatched = true; + if (budget.fits(required, leg)) return schedule.id; + } + if (!corridorMatched) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); } throw new ConflictException('Train is full — no export capacity left for this day'); } @@ -1009,8 +1016,8 @@ export class BookingBatchService implements OnModuleInit { const wagonLengths = await this.loadWagonLengths(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - let budget = await this.remainingCapacity(schedule, limits, wagonLengths); - if (budget.wagons <= 0) { + const budget = await this.remainingBudget(schedule, limits, wagonLengths); + if (budget.maxRemaining().wagons <= 0) { await this.setWindow(scheduleId, "FULL"); return; } @@ -1026,16 +1033,20 @@ export class BookingBatchService implements OnModuleInit { ? this.combinedNeed(booking, partner, wagonLengths) : this.needFor(booking, wagonLengths); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); + // Consolidated partners always share one corridor, so the primary's leg + // stands for the pair. + const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); - if (!this.fits(need, budget)) { + if (!budget.fits(need, leg)) { if (isGov) { - budget = await this.preemptForGovernment( + const freed = await this.preemptForGovernment( scheduleId, need, + leg, budget, wagonLengths, ); - if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt + if (!freed) continue; // still doesn't fit even after preempt } else { continue; // skip a unit that exceeds weight/length/wagons, try the next } @@ -1049,11 +1060,11 @@ export class BookingBatchService implements OnModuleInit { if (partner) await this.reserve(partner, scheduleId); armed = true; } - budget = this.subtract(budget, need); - if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board + budget.subtract(need, leg); + if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); + if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1106,8 +1117,8 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); - // Live per-schedule budget + arm flag, in departure order. - const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + // Live per-schedule corridor budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1120,18 +1131,18 @@ export class BookingBatchService implements OnModuleInit { } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingCapacity( - schedule, - limits, - wagonLengths, - ); + const budget = await this.remainingBudget(schedule, limits, wagonLengths); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; - const pool = await this.bookingsRepository.findBatchPoolByRouteDay( - originYardId, - destinationYardId, + // The day pool covers every booking whose leg lies somewhere on one of the + // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an + // Addis→Djibouti train). Which train actually takes a booking is decided + // by the per-train legOf check below. + const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))]; + const pool = await this.bookingsRepository.findBatchPoolByCorridorDay( + corridorYards, day, ); // Consolidated partners collapse into one atomic unit (both-or-neither); a @@ -1146,20 +1157,30 @@ export class BookingBatchService implements OnModuleInit { : this.needFor(booking, wagonLengths); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - // First train (earliest departure) that fits this unit as-is. - let target = trains.find((t) => this.fits(need, t.budget)); + const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => + t.budget.legOf(booking.originYardId, booking.destinationYardId); + + // First train (earliest departure) whose corridor carries this booking's + // leg and still fits it as-is. + let target = trains.find((t) => { + const leg = legOn(t); + return leg != null && t.budget.fits(need, leg); + }); if (!target && isGov) { // Government fits nowhere on its own — try to preempt commercial - // on each train (earliest first) until one frees enough room. + // on each corridor-matching train (earliest first) until one frees room. for (const t of trains) { - t.budget = await this.preemptForGovernment( + const leg = legOn(t); + if (!leg) continue; + const freed = await this.preemptForGovernment( t.id, need, + leg, t.budget, wagonLengths, ); - if (this.fits(need, t.budget)) { + if (freed) { target = t; break; } @@ -1170,10 +1191,15 @@ export class BookingBatchService implements OnModuleInit { // A consolidated pair is placed whole or not at all — never split. if (!isPair) { // Fits no train whole. Import GENERAL-contract commercial bookings get a - // partial-capacity offer on the train with the most free wagons. - const partialTarget = [...trains] - .filter((t) => t.budget.wagons >= 1) - .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + // partial-capacity offer on the train with the most free wagons on the + // booking's own leg. + const partialTarget = trains + .map((t) => { + const leg = legOn(t); + return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null; + }) + .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) + .sort((a, b) => b.room.wagons - a.room.wagons)[0]; if ( partialTarget && !booking.isGovernment && @@ -1183,13 +1209,13 @@ export class BookingBatchService implements OnModuleInit { ) { const offered = await this.tryPartialOffer( booking, - partialTarget.id, - partialTarget.budget, + partialTarget.t.id, + partialTarget.room, need, ); if (offered) { - partialTarget.budget = this.subtract(partialTarget.budget, offered); - partialTarget.armed = true; + partialTarget.t.budget.subtract(offered, partialTarget.leg); + partialTarget.t.armed = true; continue; } } @@ -1208,11 +1234,11 @@ export class BookingBatchService implements OnModuleInit { if (partner) await this.reserve(partner, target.id); target.armed = true; } - target.budget = this.subtract(target.budget, need); + target.budget.subtract(need, legOn(target)!); } for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL"); + if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -1414,10 +1440,10 @@ export class BookingBatchService implements OnModuleInit { "Target schedule is not accepting bookings", ); } - if ( - schedule.originStationId !== booking.originYardId || - schedule.destinationStationId !== booking.destinationYardId - ) { + const stops = await this.stopsForSchedule(schedule); + const fromIdx = stops.indexOf(booking.originYardId); + const toIdx = stops.indexOf(booking.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException( "Target schedule is not on the booking route", ); @@ -1461,13 +1487,14 @@ export class BookingBatchService implements OnModuleInit { // ---- intercity ride-along API --------------------------------------------- /** - * Remaining capacity budget (wagons / weight / length) for a schedule, and - * the per-booking need calculator — exposed for the intercity accept flow, - * which reserves ride-along bookings onto import/export trains outside the - * batch engine. + * Remaining corridor capacity budget (per-edge wagons / weight / length) for + * a schedule, and the per-booking need calculator — exposed for the intercity + * accept flow, which reserves ride-along bookings onto import/export trains + * outside the batch engine. Segment-based: an intercity booking fits whenever + * ITS leg has room, even if the train is full on other legs. */ async intercityCapacity(scheduleId: string): Promise<{ - budget: Capacity; + budget: CorridorBudget; needFor: (booking: Booking) => Capacity; } | null> { const schedule = @@ -1477,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonLengths); return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; } @@ -1635,13 +1662,17 @@ export class BookingBatchService implements OnModuleInit { /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. + * Only victims whose legs overlap the government booking's leg actually free useful + * room, so others are skipped. Mutates `budget`; returns whether the need now fits. */ private async preemptForGovernment( scheduleId: string, need: Capacity, - budget: Capacity, + leg: CorridorLeg, + budget: CorridorBudget, wagonLengths: WagonLengths, - ): Promise { + ): Promise { + if (budget.fits(need, leg)) return true; const reservedCommercial = ( await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); @@ -1655,9 +1686,16 @@ export class BookingBatchService implements OnModuleInit { (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), ); - let freed = budget; for (const victim of candidates) { - if (this.fits(need, freed)) break; + if (budget.fits(need, leg)) break; + const victimLeg = budget.legForYards( + victim.originYardId, + victim.destinationYardId, + ); + // Displacing a booking on a disjoint leg frees nothing the government + // booking can use — don't kill it for nothing. + const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; + if (!overlaps) continue; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, @@ -1680,9 +1718,9 @@ export class BookingBatchService implements OnModuleInit { ); }); this.notifier.displaced(victim); - freed = this.add(freed, this.needFor(victim, wagonLengths)); + budget.add(this.needFor(victim, wagonLengths), victimLeg); } - return freed; + return budget.fits(need, leg); } // ---- capacity helpers ----------------------------------------------------- @@ -1790,22 +1828,6 @@ export class BookingBatchService implements OnModuleInit { ); } - private subtract(budget: Capacity, need: Capacity): Capacity { - return { - wagons: budget.wagons - need.wagons, - weightTons: budget.weightTons - need.weightTons, - lengthMeters: budget.lengthMeters - need.lengthMeters, - }; - } - - private add(budget: Capacity, freed: Capacity): Capacity { - return { - wagons: budget.wagons + freed.wagons, - weightTons: budget.weightTons + freed.weightTons, - lengthMeters: budget.lengthMeters + freed.lengthMeters, - }; - } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ private async capacityLimits( locomotive: Locomotive, @@ -1883,37 +1905,68 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: {} }); } - /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ - private async remainingCapacity( + /** + * Ordered stop yards of the schedule's route (origin → milestones → + * destination); the legacy two-stop pseudo-route when milestones are absent. + */ + private async stopsForSchedule(schedule: TrainSchedule): Promise { + let milestoneYards: string[] | null = null; + if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId); + } + return stopYardsFor( + milestoneYards, + schedule.originStationId, + schedule.destinationStationId, + ); + } + + /** + * Remaining capacity per corridor edge = hard caps minus what allocated + + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only + * Dire→Djibouti leaves the Addis→Dire edges untouched. + */ + private async remainingBudget( schedule: TrainSchedule, limits: Capacity, wagonLengths: WagonLengths, - ): Promise { + ): Promise { + const stops = await this.stopsForSchedule(schedule); + const budget = new CorridorBudget(stops, limits); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); const reserved = await this.bookingsRepository.findReservedForSchedule( schedule.id, ); - const used = [...allocated, ...reserved].reduce( - (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), - { wagons: 0, weightTons: 0, lengthMeters: 0 }, - ); - return this.subtract(limits, used); + for (const b of [...allocated, ...reserved]) { + budget.subtract( + this.needFor(b, wagonLengths), + budget.legForYards(b.originYardId, b.destinationYardId), + ); + } + return budget; } - /** maxWagons minus wagons already taken by allocated + reserved bookings. */ + /** + * Wagon slots still boardable somewhere on the corridor (most-open edge). + * ≤ 0 means no leg can take another booking — the train-wide FULL signal. + */ private async remainingWagons(schedule: TrainSchedule): Promise { - const allocated = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, + const wagonLengths = await this.loadWagonLengths(); + const budget = await this.remainingBudget( + schedule, + { + wagons: schedule.maxWagons ?? 0, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + wagonLengths, ); - const used = - allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + - reserved.reduce((s, b) => s + this.wagonsFor(b), 0); - return (schedule.maxWagons ?? 0) - used; + return budget.maxRemaining().wagons; } async setWindow( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts new file mode 100644 index 000000000..cdc58083c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -0,0 +1,394 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + Optional, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Per-booking journey along a train's corridor — for EVERY trade direction. + * + * A booking rides only its own origin→destination leg, so "dispatched" and + * "arrived" are per-booking facts confirmed by the yard operator, not train + * facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and + * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export, + * → COMPLETED for intercity), possibly long before the train's final arrival. + * Both are gated on the train's latest recorded checkpoint being at that yard. + * + * Unloading also settles the physical wagons: each wagon that alights with the + * booking is released at that yard and the move is written to the + * wagon_movements ledger. + */ +@Injectable() +export class BookingJourneyService { + private readonly logger = new Logger(BookingJourneyService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + ) {} + + /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + private canLoad(booking: Booking): boolean { + if (booking.status === 'PAID') return true; + return booking.isGovernment && booking.status === 'APPROVED'; + } + + async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) { + const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + if (booking.loadedAt || booking.status === 'IN_TRANSIT') { + throw new BadRequestException('Booking is already loaded'); + } + if (!this.canLoad(booking)) { + throw new BadRequestException( + `Booking must be paid before loading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Booking).update(bookingId, { + status: 'IN_TRANSIT', + loadedAt: now, + loadedByUserId: userId ?? null, + } as never); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + }); + + // Customer tracking: cargo is on the train — loading milestones plus the + // direction's "departed" handoff. Doc-trigger path no-ops non-customs + // bookings (intercity) and already-completed codes. + void this.completeMilestones(booking, [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + ...(booking.tradeDirection === 'IMPORT' + ? ['DEPARTED_FROM_DJIBOUTI'] + : booking.tradeDirection === 'EXPORT' + ? ['DEPARTED_TO_DJIBOUTI'] + : []), + ]); + + return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() }; + } + + async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) { + const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + if (booking.status !== 'IN_TRANSIT') { + throw new BadRequestException( + `Booking must be loaded/in transit before unloading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + + // Intercity has no clearance/delivery tail — unloading completes it. Import/ + // export continue into clearance, keyed on the booking's own arrival. + const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED'; + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Booking).update(bookingId, { + status: nextStatus, + arrivedAt: now, + arrivedByUserId: userId ?? null, + } as never); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); + await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); + }); + + // Customer tracking: THIS booking arrived (train may still be rolling). + void this.completeMilestones(booking, [ + ...(booking.tradeDirection === 'IMPORT' + ? ['ARRIVED_ETHIOPIA'] + : booking.tradeDirection === 'EXPORT' + ? ['ARRIVED_AT_DJIBOUTI'] + : []), + ]); + + return { bookingId, status: nextStatus, arrivedAt: now.toISOString() }; + } + + /** + * Per-yard operator worklist for a schedule: which bookings board / alight at + * each stop, with their journey state, so the yard operator at Dire sees + * exactly what to load and unload when the train is there. + */ + async listYardWork(scheduleId: string) { + const schedule = await this.getSchedule(scheduleId); + const bookings = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .innerJoin( + 'freight.train_schedule_bookings', + 'tsb', + 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', + { scheduleId }, + ) + .getMany(); + + const latest = await this.latestCheckpoint(scheduleId); + const yardIds = [ + ...new Set( + bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean), + ), + ]; + const yards = yardIds.length + ? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } }) + : []; + const yardById = new Map(yards.map((y) => [y.id, y])); + const yardLabel = (id: string) => + yardById.get(id)?.label ?? yardById.get(id)?.code ?? id; + + const mapBooking = (b: Booking) => ({ + id: b.id, + reference: b.reference, + status: b.status, + tradeDirection: b.tradeDirection, + isGovernment: b.isGovernment, + customer: b.company?.name ?? 'Unknown customer', + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + origin: yardLabel(b.originYardId), + destination: yardLabel(b.destinationYardId), + loadedAt: b.loadedAt?.toISOString() ?? null, + arrivedAt: b.arrivedAt?.toISOString() ?? null, + canLoad: !b.loadedAt && this.canLoad(b), + canUnload: b.status === 'IN_TRANSIT', + }); + + const byYard = new Map< + string, + { yardId: string; yard: string; toLoad: ReturnType[]; toUnload: ReturnType[] } + >(); + const bucket = (yardId: string) => { + let entry = byYard.get(yardId); + if (!entry) { + entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] }; + byYard.set(yardId, entry); + } + return entry; + }; + for (const b of bookings) { + bucket(b.originYardId).toLoad.push(mapBooking(b)); + bucket(b.destinationYardId).toUnload.push(mapBooking(b)); + } + + return { + scheduleId, + scheduleStatus: schedule.status, + trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId), + yards: [...byYard.values()], + }; + } + + /** + * Bulk fallback at the train's FINAL arrival: any booking destined for the + * final yard that operators didn't unload individually gets its per-booking + * arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT + * touched — their arrival is their own unload. Returns the affected ids. + */ + async autoArriveAtFinalYard( + manager: EntityManager, + schedule: TrainSchedule, + now: Date, + ): Promise { + const rows: Array<{ id: string; trade_direction: string }> = await manager.query( + `UPDATE freight.bookings b + SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END, + scheduling_status = 'DISPATCHED', + arrived_at = COALESCE(b.arrived_at, $3), + loaded_at = COALESCE(b.loaded_at, b.created_at) + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.destination_yard_id = $2 + AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED') + RETURNING b.id, b.trade_direction`, + [schedule.id, schedule.destinationStationId, now], + ); + return rows.map((r) => r.id); + } + + // ---- helpers --------------------------------------------------------------- + + private async getSchedule(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return schedule; + } + + private async getScheduleBooking(scheduleId: string, bookingId: string) { + const schedule = await this.getSchedule(scheduleId); + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not assigned to this schedule'); + } + return { schedule, booking }; + } + + private async latestCheckpoint(scheduleId: string): Promise { + return this.dataSource.getRepository(TrainCheckpointEvent).findOne({ + where: { trainScheduleId: scheduleId }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + } + + /** + * The train is "at" a yard when the latest recorded checkpoint is that yard, + * or — for a booking boarding at the train's own origin — when the train has + * not recorded any checkpoint yet (still sitting at its origin). + */ + private async assertTrainAtYard( + schedule: TrainSchedule, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + const latest = await this.latestCheckpoint(schedule.id); + if (!latest) { + if (side === 'origin' && schedule.originStationId === yardId) return; + throw new BadRequestException( + 'Train has not reached this yard yet — record its checkpoint first', + ); + } + if (latest.yardId !== yardId) { + throw new BadRequestException( + `Train's last recorded position is not at the booking's ${side} yard`, + ); + } + } + + private async setAllocationStatuses( + manager: EntityManager, + scheduleId: string, + bookingId: string, + status: 'LOADED' | 'DEPARTED', + ): Promise { + const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId); + if (!allocations.length) return; + await manager + .getRepository(WagonBookingAllocation) + .update({ id: In(allocations.map((a) => a.id)) }, { status }); + } + + private async allocationsForBooking( + manager: EntityManager, + scheduleId: string, + bookingId: string, + ): Promise> { + return manager + .getRepository(WagonBookingAllocation) + .createQueryBuilder('alloc') + .innerJoinAndSelect('alloc.trainSetWagon', 'slot') + .innerJoin( + 'freight.train_schedules', + 'schedule', + 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', + { scheduleId }, + ) + .where('alloc.booking_id = :bookingId', { bookingId }) + .getMany(); + } + + /** + * On unload: write the wagon_movements ledger rows (board yard → unload yard, + * kind LOADED) for the booking's pinned wagons, and release each wagon whose + * slot alights here — it detaches, stays at this yard, and becomes Available + * (dynamic consist). Wagons shared with a still-loaded consolidated partner + * stay pinned until the last booking on the slot unloads. + */ + private async settleWagonsOnUnload( + manager: EntityManager, + schedule: TrainSchedule, + booking: Booking, + now: Date, + userId: string | null, + ): Promise { + const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id); + for (const alloc of allocations) { + const slot = alloc.trainSetWagon; + if (!slot?.physicalWagonId) continue; + + const boardYardId = slot.boardYardId ?? schedule.originStationId; + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: slot.physicalWagonId, + fromYardId: boardYardId, + toYardId: booking.destinationYardId, + trainScheduleId: schedule.id, + bookingId: booking.id, + kind: Freight.WagonMovementKind.Loaded, + movedByUserId: userId, + occurredAt: now, + }), + ); + + // Detach only when this yard is where the slot's leg ends and no other + // booking on the wagon is still in transit. + const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId; + if (slotAlightYardId !== booking.destinationYardId) continue; + const siblings = await manager + .getRepository(WagonBookingAllocation) + .createQueryBuilder('alloc') + .innerJoin('alloc.booking', 'b') + .where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id }) + .andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id }) + .andWhere(`b.status = 'IN_TRANSIT'`) + .getCount(); + if (siblings > 0) continue; + + await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' }); + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + // Only settle a wagon still bound to this schedule (it may have been + // re-pinned elsewhere already). + if (wagon && wagon.currentTrainScheduleId === schedule.id) { + await manager.getRepository(Wagon).update(wagon.id, { + currentYardId: booking.destinationYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + status: Freight.WagonStatus.Available, + }); + } + } + } + + private async completeMilestones(booking: Booking, codes: string[]): Promise { + if (!this.milestoneService || !codes.length) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts new file mode 100644 index 000000000..b16b25179 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -0,0 +1,148 @@ +/** + * Segment (leg) aware capacity accounting for corridor bookings. + * + * A train's route is an ordered list of stops; a booking occupies only the + * edges between its own origin and destination. Capacity (wagons / weight / + * length) is therefore tracked PER EDGE, not per train: two bookings whose + * legs don't overlap (Addis→Dire and Dire→Djibouti) consume the same wagon + * budget on disjoint edges and can share physical wagons. + * + * Legacy schedules without route milestones degrade to a single-edge corridor + * ([origin, destination]) where this is exactly the old train-wide math. + */ + +export interface Capacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +/** Half-open edge span along the stop list: occupies edges [fromEdge, toEdge). */ +export interface CorridorLeg { + fromEdge: number; + toEdge: number; +} + +export function addCapacity(a: Capacity, b: Capacity): Capacity { + return { + wagons: a.wagons + b.wagons, + weightTons: a.weightTons + b.weightTons, + lengthMeters: a.lengthMeters + b.lengthMeters, + }; +} + +export function subtractCapacity(a: Capacity, b: Capacity): Capacity { + return { + wagons: a.wagons - b.wagons, + weightTons: a.weightTons - b.weightTons, + lengthMeters: a.lengthMeters - b.lengthMeters, + }; +} + +export function capacityFits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); +} + +/** + * Ordered stop yard ids for a schedule. Route milestones (already ordered by + * sequence) when there are at least two; otherwise the schedule's own + * origin/destination pair — the legacy two-stop pseudo-route. + */ +export function stopYardsFor( + milestoneYardIdsInOrder: string[] | null | undefined, + originStationId: string, + destinationStationId: string, +): string[] { + if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) { + return milestoneYardIdsInOrder; + } + return [originStationId, destinationStationId]; +} + +/** Per-edge capacity budget along a schedule's stop list. */ +export class CorridorBudget { + private readonly edges: Capacity[]; + private readonly stopIndex: Map; + + constructor( + readonly stops: string[], + initial: Capacity, + ) { + const edgeCount = Math.max(1, stops.length - 1); + this.edges = Array.from({ length: edgeCount }, () => ({ ...initial })); + this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + } + + /** The leg between two stops, or null when they aren't on this corridor in order. */ + legOf(originYardId: string, destinationYardId: string): CorridorLeg | null { + const from = this.stopIndex.get(originYardId); + const to = this.stopIndex.get(destinationYardId); + if (from == null || to == null || from >= to) return null; + return { fromEdge: from, toEdge: to }; + } + + /** Every edge — for whole-route consumers and unknown-leg fallbacks. */ + fullLeg(): CorridorLeg { + return { fromEdge: 0, toEdge: this.edges.length }; + } + + /** + * The leg a booking occupies; bookings whose yards aren't on the corridor + * (legacy data drift) conservatively occupy the whole route so capacity is + * never double-booked against them. + */ + legForYards(originYardId: string, destinationYardId: string): CorridorLeg { + return this.legOf(originYardId, destinationYardId) ?? this.fullLeg(); + } + + /** Remaining capacity usable by this leg = min across its edges. */ + remainingFor(leg: CorridorLeg): Capacity { + let min = { ...this.edges[leg.fromEdge] }; + for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) { + const e = this.edges[i]; + min = { + wagons: Math.min(min.wagons, e.wagons), + weightTons: Math.min(min.weightTons, e.weightTons), + lengthMeters: Math.min(min.lengthMeters, e.lengthMeters), + }; + } + return min; + } + + fits(need: Capacity, leg: CorridorLeg): boolean { + return capacityFits(need, this.remainingFor(leg)); + } + + subtract(need: Capacity, leg: CorridorLeg): void { + for (let i = leg.fromEdge; i < leg.toEdge; i++) { + this.edges[i] = subtractCapacity(this.edges[i], need); + } + } + + add(freed: Capacity, leg: CorridorLeg): void { + for (let i = leg.fromEdge; i < leg.toEdge; i++) { + this.edges[i] = addCapacity(this.edges[i], freed); + } + } + + /** + * The most open edge — when even this has no wagon slots left, nothing can + * board anywhere and the schedule's window is genuinely FULL. (A train can be + * full on one leg while another still has room, so train-wide FULL keys on + * the max, not the min.) + */ + maxRemaining(): Capacity { + return this.edges.reduce( + (max, e) => ({ + wagons: Math.max(max.wagons, e.wagons), + weightTons: Math.max(max.weightTons, e.weightTons), + lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), + }), + { ...this.edges[0] }, + ); + } +} 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 659eea456..bce4207d3 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 @@ -10,8 +10,8 @@ import { DataSource } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { BookingBatchService, type Capacity } from './booking-batch.service'; -import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingJourneyService } from './booking-journey.service'; /** * Intercity (DOMESTIC) ride-along: intercity bookings never get their own @@ -32,6 +32,7 @@ export class IntercityService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, + private readonly bookingJourneyService: BookingJourneyService, ) {} /** @@ -52,13 +53,20 @@ export class IntercityService { return { scheduleId, routeId: schedule.routeId ?? null, - remaining: capacity?.budget ?? null, + // Segment-based: "remaining" is the most-open edge; each candidate's + // `fits` is judged against ITS OWN leg, so a booking on a free leg fits + // even when the train is full elsewhere. + remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; + const leg = capacity?.budget.legOf( + booking.originYardId, + booking.destinationYardId, + ); return { ...this.mapBooking(booking), need, - fits: need && capacity ? fits(need, capacity.budget) : false, + fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => ({ @@ -94,7 +102,7 @@ export class IntercityService { const accepted: string[] = []; const rejected: Array<{ bookingId: string; reason: string }> = []; - let budget = capacity.budget; + const budget = capacity.budget; for (const bookingId of bookingIds) { const booking = await this.dataSource @@ -110,45 +118,35 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - if (!fits(need, budget)) { + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + // Segment-based: only the booking's own leg must have room, so an + // intercity booking still boards a train that is full on other legs. + if (!leg || !budget.fits(need, leg)) { rejected.push({ bookingId, - reason: 'Does not fit the remaining wagon/weight/length capacity', + reason: + 'Does not fit the remaining wagon/weight/length capacity on its leg', }); continue; } await this.bookingBatchService.acceptIntercity(booking, scheduleId); - budget = subtract(budget, need); + budget.subtract(need, leg); accepted.push(bookingId); this.logger.log( `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, ); } - return { accepted, rejected, remaining: budget }; + return { accepted, rejected, remaining: budget.maxRemaining() }; } /** - * Mark an accepted intercity booking's cargo as loaded. Only allowed while - * the train is physically at the booking's origin yard: either it has not - * departed yet and the booking boards at the train's own origin, or the - * latest recorded checkpoint is at the booking's origin yard. + * Mark an accepted intercity booking's cargo as loaded. Delegates to the + * shared per-booking journey flow (same checkpoint gating as import/export). */ async loadBooking(scheduleId: string, bookingId: string) { - const { schedule, booking } = await this.getAcceptedBooking( - scheduleId, - bookingId, - ); - if (booking.status !== 'PAID') { - throw new BadRequestException( - `Booking must be paid before loading (currently ${booking.status})`, - ); - } - await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); - await this.dataSource - .getRepository(Booking) - .update(bookingId, { status: 'IN_TRANSIT' }); - return { bookingId, status: 'IN_TRANSIT' as const }; + await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard + return this.bookingJourneyService.loadBooking(scheduleId, bookingId); } /** @@ -156,20 +154,8 @@ export class IntercityService { * requires the latest checkpoint to be at that yard. Completes the booking. */ async unloadBooking(scheduleId: string, bookingId: string) { - const { schedule, booking } = await this.getAcceptedBooking( - scheduleId, - bookingId, - ); - if (booking.status !== 'IN_TRANSIT') { - throw new BadRequestException( - `Booking must be loaded/in transit before unloading (currently ${booking.status})`, - ); - } - await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); - await this.dataSource - .getRepository(Booking) - .update(bookingId, { status: 'COMPLETED' }); - return { bookingId, status: 'COMPLETED' as const }; + await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard + return this.bookingJourneyService.unloadBooking(scheduleId, bookingId); } // ---- helpers --------------------------------------------------------------- @@ -298,36 +284,6 @@ export class IntercityService { return { schedule, booking }; } - /** - * The train is "at" a yard when the latest recorded checkpoint is that yard, - * or — for a booking boarding at the train's own origin — when the train has - * not recorded any checkpoint yet (still sitting at its origin). - */ - private async assertTrainAtYard( - schedule: TrainSchedule, - yardId: string, - side: 'origin' | 'destination', - ): Promise { - const latest = await this.dataSource - .getRepository(TrainCheckpointEvent) - .findOne({ - where: { trainScheduleId: schedule.id }, - order: { occurredAt: 'DESC', createdAt: 'DESC' }, - }); - - if (!latest) { - if (side === 'origin' && schedule.originStationId === yardId) return; - throw new BadRequestException( - 'Train has not reached this yard yet — record its checkpoint first', - ); - } - if (latest.yardId !== yardId) { - throw new BadRequestException( - `Train's last recorded position is not at the booking's ${side} yard`, - ); - } - } - private mapBooking(booking: Booking) { return { id: booking.id, @@ -350,18 +306,3 @@ export class IntercityService { } } -function fits(need: Capacity, budget: Capacity): boolean { - return ( - need.wagons <= budget.wagons && - need.weightTons <= budget.weightTons && - need.lengthMeters <= budget.lengthMeters - ); -} - -function subtract(budget: Capacity, need: Capacity): Capacity { - return { - wagons: budget.wagons - need.wagons, - weightTons: budget.weightTons - need.weightTons, - lengthMeters: budget.lengthMeters - need.lengthMeters, - }; -} 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 1648bd957..7dba44f83 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 @@ -47,6 +47,7 @@ import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.d import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingJourneyService } from "./booking-journey.service"; import { BookingWindowService } from "./booking-window.service"; import { IntercityService } from "./intercity.service"; import { BillingService } from "../billing/billing.service"; @@ -60,6 +61,7 @@ export class TrainSchedulingController { private readonly bookingBatchService: BookingBatchService, private readonly bookingWindowService: BookingWindowService, private readonly intercityService: IntercityService, + private readonly bookingJourneyService: BookingJourneyService, private readonly billingService: BillingService, ) { } @@ -432,6 +434,42 @@ export class TrainSchedulingController { return this.intercityService.acceptBookings(id, dto.bookingIds); } + @Get("schedules/:id/yard-work") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Per-yard operator worklist: which bookings board/alight at each stop, with journey state", + }) + getYardWork(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingJourneyService.listYardWork(id); + } + + @Post("schedules/:id/bookings/:bookingId/load") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", + }) + loadScheduleBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingJourneyService.loadBooking(id, bookingId); + } + + @Post("schedules/:id/bookings/:bookingId/unload") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", + }) + unloadScheduleBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingJourneyService.unloadBooking(id, bookingId); + } + @Post("schedules/:id/intercity/:bookingId/load") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 792fb7c64..1e1eb1695 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -30,8 +30,10 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowService } from './booking-window.service'; import { IntercityService } from './intercity.service'; import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingJourneyService } from './booking-journey.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; @@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEvent, ImportDjiboutiOperation, BookingBatchOffer, + WagonMovement, // WsAuthService (booking-window gateway handshake) verifies IAM sessions. Session, ]), @@ -77,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingWindowService, BookingSplitService, IntercityService, + BookingJourneyService, ], exports: [ TrainSchedulingService, 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 6aecd94a8..0dba6b3de 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 @@ -155,6 +155,9 @@ describe('TrainSchedulingService', () => { htmlToPdfBuffer: jest.fn(), } as never, { emitPhase: jest.fn() } as never, // bookingWindowGateway + { + autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), + } as never, // bookingJourneyService ); const defaultFleetWagons = [ 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 7211d63fd..4ccd123cc 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 @@ -4,6 +4,7 @@ SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, + WagonMovementKind, WagonStatus, } from '@edr/types'; import { @@ -27,6 +28,7 @@ import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { formatRouteLabel, Route } from '../routes/entities/route.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; @@ -113,6 +115,7 @@ import { eatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { BookingJourneyService } from './booking-journey.service'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; @@ -276,6 +279,7 @@ export class TrainSchedulingService { private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, + private readonly bookingJourneyService: BookingJourneyService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} @@ -290,15 +294,26 @@ export class TrainSchedulingService { private async completeMilestonesForScheduleBookings( scheduleId: string, codes: string[], + filter?: { originYardId?: string; destinationYardId?: string }, ): Promise { if (!this.milestoneService || codes.length === 0) return; try { + const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL']; + const params: unknown[] = [scheduleId]; + if (filter?.originYardId) { + params.push(filter.originYardId); + conditions.push(`b.origin_yard_id = $${params.length}`); + } + if (filter?.destinationYardId) { + params.push(filter.destinationYardId); + conditions.push(`b.destination_yard_id = $${params.length}`); + } const rows: Array<{ booking_id: string }> = await this.dataSource.query( `SELECT tsb.booking_id FROM freight.train_schedule_bookings tsb - WHERE tsb.train_schedule_id = $1 - AND tsb.deleted_at IS NULL`, - [scheduleId], + JOIN freight.bookings b ON b.id = tsb.booking_id + WHERE ${conditions.join(' AND ')}`, + params, ); for (const { booking_id } of rows) { for (const code of codes) { @@ -1457,6 +1472,24 @@ export class TrainSchedulingService { manager, ); } + // Per-booking journey fallback: bookings boarding at the TRAIN's origin + // that the operator didn't load individually are auto-loaded now — the + // train is leaving with them. Mid-corridor boarders stay PAID until the + // operator loads them at their own yard. + await manager.query( + `UPDATE freight.bookings b + SET status = 'IN_TRANSIT', + loaded_at = COALESCE(b.loaded_at, $3) + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`, + [scheduleId, schedule.originStationId, now], + ); // Close the booking window; any still-pending (unallocated) reservations don't ride this train. await manager .getRepository(TrainSchedule) @@ -1488,18 +1521,24 @@ export class TrainSchedulingService { // Dispatch closed the window — drop it from portal/GL cards right away. void this.emitWindowState(scheduleId); // Customer tracking: cargo is on the departing train — loading milestones - // plus the direction's "departed" handoff milestone. + // plus the direction's "departed" handoff milestone. Restricted to bookings + // that BOARD at the train's origin; mid-corridor boarders get their loading + // milestones from their own operator load at their own yard. if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { - void this.completeMilestonesForScheduleBookings(scheduleId, [ - // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the - // doc-trigger path no-ops it for import bookings. - 'CARGO_ARRIVED', - 'READY_FOR_LOADING', - 'LOADED', - schedule.direction === 'IMPORT' - ? 'DEPARTED_FROM_DJIBOUTI' - : 'DEPARTED_TO_DJIBOUTI', - ]); + void this.completeMilestonesForScheduleBookings( + scheduleId, + [ + // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the + // doc-trigger path no-ops it for import bookings. + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + schedule.direction === 'IMPORT' + ? 'DEPARTED_FROM_DJIBOUTI' + : 'DEPARTED_TO_DJIBOUTI', + ], + { originYardId: schedule.originStationId }, + ); } return this.getTrainScheduleById(scheduleId); } @@ -2426,18 +2465,11 @@ export class TrainSchedulingService { }); } - await manager.query( - `UPDATE freight.bookings b - SET status = $2, - scheduling_status = $3 - FROM freight.train_schedule_bookings tsb - WHERE tsb.booking_id = b.id - AND tsb.train_schedule_id = $1 - AND tsb.deleted_at IS NULL - AND b.deleted_at IS NULL - AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`, - [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], - ); + // Per-booking journey: bookings destined for the FINAL yard that the + // operator didn't unload individually get their arrival stamped now as a + // bulk fallback. Mid-corridor bookings are NOT touched — their arrival is + // their own unload (possibly already done while the train kept rolling). + await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); // Release every locomotive of the set (not just the legacy primary) and move it // to the destination yard where it physically arrived. @@ -2455,12 +2487,33 @@ export class TrainSchedulingService { .getRepository(Wagon) .findOne({ where: { id: slot.physicalWagonId } }); if (!wagon) continue; + // A wagon that already alighted mid-route (unload released it, possibly + // re-pinned elsewhere since) is no longer this schedule's to move. + if (wagon.currentTrainScheduleId !== scheduleId) continue; + // Dynamic consist: the wagon settles at its slot's alight yard, not + // blanket at the train's destination. + const settleYardId = slot.alightYardId ?? schedule.destinationStationId; await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, status: WagonStatus.Available, - currentYardId: schedule.destinationStationId, + currentYardId: settleYardId, }); + // Ledger: the wagon rode this schedule to its settle yard. + const slotAllocations = slot.allocations ?? []; + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: slot.boardYardId ?? schedule.originStationId, + toYardId: settleYardId, + trainScheduleId: scheduleId, + bookingId: slotAllocations[0]?.bookingId ?? null, + kind: slotAllocations.length + ? WagonMovementKind.Loaded + : WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); } // Ensure a destination checkpoint exists so the timeline shows ARRIVED. @@ -2482,11 +2535,15 @@ export class TrainSchedulingService { } }); - // Customer tracking: the train reached the corridor's far end. + // Customer tracking: the train reached the corridor's far end. Restricted + // to bookings destined for the FINAL yard — mid-corridor bookings get their + // arrival milestone from their own operator unload at their own yard. if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { - void this.completeMilestonesForScheduleBookings(scheduleId, [ - schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI', - ]); + void this.completeMilestonesForScheduleBookings( + scheduleId, + [schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'], + { destinationYardId: schedule.destinationStationId }, + ); } const detail = await this.getTrainScheduleById(scheduleId); @@ -2635,17 +2692,26 @@ export class TrainSchedulingService { } if ( - bookings.some((b) => { - if (targetScheduleId && b.trainScheduleId === targetScheduleId) { - return false; + 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); } - return ( - b.originYardId !== dto.originStationId || - b.destinationYardId !== dto.destinationStationId - ); - }) + 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; + }); + })() ) { - violations.push('Selected bookings must share the same origin and destination as the schedule'); + violations.push('Selected bookings must lie on the schedule route (origin before destination)'); } if (!forceAssign) { @@ -2695,7 +2761,37 @@ export class TrainSchedulingService { } const originYardId = dto.originStationId; - const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId); + // Dynamic consist: a slot's physical wagon may ride from the train's origin + // OR already sit at the booking's own boarding yard and attach there — so + // the usable fleet is the union across the origin and every boarding yard. + const boardYardIds = [ + ...new Set( + [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), + ), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => + this.countFleetAvailability(yardId, targetScheduleId), + ), + ); + const mergedFleet = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + const existing = mergedFleet.get(row.wagonTypeId) ?? { + code: row.wagonTypeCode, + available: 0, + }; + existing.available += row.available; + mergedFleet.set(row.wagonTypeId, existing); + } + } + const fleetCounts = [...mergedFleet.entries()].map( + ([wagonTypeId, value]) => ({ + wagonTypeId, + wagonTypeCode: value.code, + available: value.available, + }), + ); const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); fleetAvailability = computeFleetAvailability( demandPlan, @@ -2720,6 +2816,12 @@ export class TrainSchedulingService { containerWagonType, bulkWagonType, }); + this.stampSlotLegs( + wagonPlan, + fittingBookings, + dto.originStationId, + dto.destinationStationId, + ); violations.push( ...(await this.validatePhysicalFleetForPlan( @@ -3047,6 +3149,7 @@ export class TrainSchedulingService { wagonTypeId: slot.wagonTypeId, wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, trainSetWagonId: slot.id, + boardYardId: slot.boardYardId ?? null, })); const unpinnable = this.findUnpinnableWagonSlots( @@ -3100,6 +3203,7 @@ export class TrainSchedulingService { sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, + boardYardId: slot.boardYardId ?? null, })), wagons, targetScheduleId, @@ -3108,7 +3212,12 @@ export class TrainSchedulingService { } private findUnpinnableWagonSlots( - slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>, + slots: Array<{ + sequenceNo: number; + wagonTypeId: string; + wagonTypeCode: string; + boardYardId?: string | null; + }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, @@ -3136,22 +3245,35 @@ export class TrainSchedulingService { return violations; } + /** + * Dynamic consist: a slot's wagon may either ride from the train's origin + * yard (attaching there, possibly empty until the slot's board yard) or + * already sit AT the slot's board yard and hook on when the train arrives. + */ private pickPhysicalWagonForSlot( - slot: { wagonTypeId: string }, + slot: { wagonTypeId: string; boardYardId?: string | null }, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, assignedPhysicalIds: Set, ): Wagon | undefined { - return wagons.find((wagon) => { + const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; if (assignedPhysicalIds.has(wagon.id)) return false; const pinnedOnSchedule = scheduleId ? wagon.currentTrainScheduleId === scheduleId : false; - if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; - return wagon.currentYardId === originYardId; - }); + return wagon.status === WagonStatus.Available || pinnedOnSchedule; + }; + // Prefer a wagon already waiting at the slot's board yard (no empty haul); + // fall back to one riding from the train's origin. + if (slot.boardYardId) { + const atBoardYard = wagons.find( + (w) => usable(w) && w.currentYardId === slot.boardYardId, + ); + if (atBoardYard) return atBoardYard; + } + return wagons.find((w) => usable(w) && w.currentYardId === originYardId); } private positiveNumber(value: number | undefined, fallback: number): number { @@ -3303,6 +3425,42 @@ export class TrainSchedulingService { return containerType?.wagonType?.isActive ? containerType.wagonType : null; } + /** + * Stamp each plan slot with the leg it occupies (dynamic consist): the + * boarding/alighting yards of the bookings it carries. Null means the + * schedule's own endpoint (whole-route slot, legacy behavior). A slot + * carrying bookings with mixed corridors stays whole-route (conservative). + */ + private stampSlotLegs( + wagonPlan: WagonPlanSlot[], + bookings: Booking[], + scheduleOriginYardId: string, + scheduleDestinationYardId: string, + ): void { + const bookingById = new Map(bookings.map((b) => [b.id, b])); + for (const slot of wagonPlan) { + const slotBookings = [ + ...new Set(slot.allocations.map((a) => a.bookingId)), + ] + .map((id) => bookingById.get(id)) + .filter((b): b is Booking => Boolean(b)); + if (!slotBookings.length) continue; + const [first] = slotBookings; + const sameCorridor = slotBookings.every( + (b) => + b.originYardId === first.originYardId && + b.destinationYardId === first.destinationYardId, + ); + if (!sameCorridor) continue; + slot.boardYardId = + first.originYardId === scheduleOriginYardId ? null : first.originYardId; + slot.alightYardId = + first.destinationYardId === scheduleDestinationYardId + ? null + : first.destinationYardId; + } + } + private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, @@ -3318,6 +3476,8 @@ export class TrainSchedulingService { lengthMeters: slot.lengthMeters, assignedWeightTons: slot.assignedWeightTons, status: 'PLANNED', + boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, }), ); return manager.getRepository(TrainSetWagon).save(wagons); @@ -4012,26 +4172,44 @@ export class TrainSchedulingService { // How many wagons of that type the cargo needs. const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); + void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - // AVAILABLE wagons of the required type, counted once per origin yard. - const availableByYard = new Map(); - const availableAt = async (yardId: string): Promise => { - const cached = availableByYard.get(yardId); - if (cached !== undefined) return cached; - const counts = await this.countFleetAvailability(yardId); - const n = - counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - availableByYard.set(yardId, n); - return n; - }; + // TEMP (per request): wagon-availability filtering is DISABLED. A day is now + // offered whenever a bookable schedule that day has remaining train capacity + // — regardless of whether matching wagons are actually available at the + // origin / boarding yard. This surfaces days even when no wagon is on hand. + // Restore the block below to bring back the "enough matching wagons" gate. + // + // // AVAILABLE wagons of the required type, counted once per origin yard. + // const availableByYard = new Map(); + // const availableAt = async (yardId: string): Promise => { + // const cached = availableByYard.get(yardId); + // if (cached !== undefined) return cached; + // const counts = await this.countFleetAvailability(yardId); + // const n = + // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; + // availableByYard.set(yardId, n); + // return n; + // }; const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - if (!enoughWagons) continue; + // TEMP (per request): wagon-availability check commented out — see note + // above. Dynamic consist: wagons may ride from the train's origin OR + // already sit at the booking's own boarding yard and attach when the train + // arrives — either pool can serve a sub-corridor booking. + // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; + // if ( + // !enoughWagons && + // input.originYardId && + // input.originYardId !== s.originStationId + // ) { + // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; + // } + // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } @@ -4063,6 +4241,33 @@ export class TrainSchedulingService { return Math.max(1, Math.ceil(teu / 2)); } + /** + * Ordered stop yards of a schedule's route: origin → milestones → destination, + * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule + * has no route milestones. Shared by corridor (sub-leg) validation everywhere. + */ + async stopYardsForSchedule(schedule: TrainSchedule): Promise { + let milestoneYards: string[] = []; + if (schedule.route?.milestones?.length) { + milestoneYards = [...schedule.route.milestones] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId); + } else if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + milestoneYards = milestones.map((m) => m.yardId); + } + const raw = milestoneYards.length >= 2 + ? milestoneYards + : [schedule.originStationId, ...milestoneYards, schedule.destinationStationId]; + const unique: string[] = []; + for (const yardId of raw) { + if (yardId && !unique.includes(yardId)) unique.push(yardId); + } + return unique; + } + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ async existsOpenScheduleOnRouteDay( originYardId: string, 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 35d5ce185..21dd7b985 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 @@ -38,6 +38,13 @@ export type WagonPlanSlot = { assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; + /** + * Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards + * at boardYardId and alights at alightYardId. Null = the schedule's own + * endpoint (whole-route slot, legacy behavior). + */ + boardYardId?: string | null; + alightYardId?: string | null; }; export type ContainerUnitRow = { diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index a220218e0..5deedb12d 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -55,6 +55,17 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) status!: string; + // ── Leg occupancy (segment corridor bookings) ────────────────────────────── + // A slot may occupy only part of the route: it boards (attaches/loads) at + // board_yard_id and alights (unloads/detaches) at alight_yard_id. NULL on both + // means the slot rides the whole route (legacy full-route bookings). Slots + // whose legs don't overlap coexist without consuming each other's capacity. + @Column({ name: 'board_yard_id', type: 'uuid', nullable: true }) + boardYardId?: string | null; + + @Column({ name: 'alight_yard_id', type: 'uuid', nullable: true }) + alightYardId?: string | null; + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) allocations?: WagonBookingAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts new file mode 100644 index 000000000..7c5ed092c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts @@ -0,0 +1,59 @@ +import { BaseEntity } from '@edr/api-common'; +import { WagonMovementKind } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Wagon } from './wagon.entity'; + +/** + * Ledger of every physical wagon relocation between yards — one row per move. + * Written when a wagon carries a booking's leg (LOADED), rides a train empty to + * reposition (EMPTY_REPOSITION), or staff manually correct its yard (MANUAL). + * `wagons.current_yard_id` is the derived "where is it now"; this table is the + * auditable history of how it got there and by whom. + */ +@Entity({ schema: 'freight', name: 'wagon_movements' }) +@Index(['wagonId', 'occurredAt']) +export class WagonMovement extends BaseEntity { + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @ManyToOne(() => Wagon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_id' }) + wagon?: Wagon; + + /** Null when the prior location is unknown (e.g. first manual registration). */ + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true }) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard | null; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard | null; + + /** Set when the move happened by riding a scheduled train (LOADED / EMPTY_REPOSITION). */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + /** Set when the move carried a specific booking's cargo (kind LOADED). */ + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'kind', type: 'varchar', length: 30 }) + kind!: WagonMovementKind; + + @Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true }) + movedByUserId?: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index ec98a4a4b..1d5287dba 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -43,6 +43,14 @@ export class WagonsController { return this.wagonsService.findById(id); } + @Get(':id/movements') + @ApiOperation({ + summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first", + }) + listMovements(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.listMovements(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a wagon' }) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index ac10a2ef3..9d1f1b41f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,4 @@ -import { WagonStatus } from '@edr/types'; +import { WagonMovementKind, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; @@ -8,6 +8,7 @@ import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; @Injectable() @@ -74,8 +75,9 @@ export class WagonsService { return wagon; } - async update(id: string, dto: UpdateWagonDto): Promise { + async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise { const wagon = await this.findById(id); + const previousYardId = wagon.currentYardId ?? null; Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation @@ -85,11 +87,40 @@ export class WagonsService { wagon.currentYard = null; } await this.wagonRepo.save(wagon); + // Staff manually relocated the wagon — write the movement ledger row so the + // wagon's yard history stays auditable (who moved it, from where, when). + if ( + dto.currentYardId !== undefined && + dto.currentYardId !== null && + dto.currentYardId !== previousYardId + ) { + const movementRepo = this.dataSource.getRepository(WagonMovement); + await movementRepo.save( + movementRepo.create({ + wagonId: id, + fromYardId: previousYardId, + toYardId: dto.currentYardId, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + } // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); } + /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ + async listMovements(wagonId: string): Promise { + await this.findById(wagonId); // 404 on unknown wagon + return this.dataSource.getRepository(WagonMovement).find({ + where: { wagonId }, + relations: { fromYard: true, toYard: true }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + } + async remove(id: string): Promise { const wagon = await this.findById(id); await this.wagonRepo.remove(wagon); diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 5fcf59dd5..41ca7facb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -340,6 +340,7 @@ export class SchedulingReadFacade { 'LOADED', 'DISPATCHED', 'IN_TRANSIT', + 'ARRIVED', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION', diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8e9457831..ee3e7c9c8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -471,7 +471,7 @@ export class WarehouseInventoryService { // ── Batch 4.5: Arrival / Unload / Load automation ────────────────────────── /** Bookings whose goods have arrived and may be unloaded into the warehouse. */ - private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT']; + private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED']; /** Arrived bookings + their current inventory/inspection state (queue view). */ async arrivalQueue(): Promise { diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts new file mode 100644 index 000000000..09901b502 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Organization, + Permission, + Position, + PositionPermission, + Unit, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; + +const SEED_FLAG = 'SEED_EDR_ORG'; +const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_app'; + +/** + * Seeds the operational freight positions (CEO, Chief, Director, Marketer, + * Operation, Ethiopian GL, Djibouti GL) as Position + PositionPermission rows + * on the `edr_freight_app` unit. Positions-as-roles: users get their freight + * access by being assigned to a Position (via EmployeePosition), and the + * position's PositionPermission grants come from EDR_FREIGHT_POSITIONS. + * + * Gated behind the same SEED_EDR_ORG flag as EdrOrgSeeder and depends on the + * org/unit/permission catalog it seeds, so it must run AFTER EdrOrgSeeder. + * Idempotent: positions upsert by (key, unitId); grants insert only the + * permission ids a position is still missing. + */ +@Injectable() +export class FreightPositionsSeeder { + private readonly logger = new Logger(FreightPositionsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log( + `Skipping freight positions seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + + const permissionKeyToId = await this.loadPermissionIds(manager); + + for (const seed of EDR_FREIGHT_POSITIONS) { + const positionId = await this.ensurePosition( + manager, + seed, + unit.id as string, + organization.id as string, + ); + + await this.ensurePositionPermissions( + manager, + positionId, + seed, + permissionKeyToId, + ); + } + }); + + this.logger.log( + `Ensured ${EDR_FREIGHT_POSITIONS.length} freight positions on unit '${EDR_UNIT_KEY}'`, + ); + } + + /** Resolve every permission key referenced by any position to its id. */ + private async loadPermissionIds( + manager: EntityManager, + ): Promise> { + const keys = [ + ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), + ]; + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(keys) }, + select: { id: true, key: true }, + }); + + const map = new Map(permissions.map((p) => [p.key, p.id as string])); + + const missing = keys.filter((key) => !map.has(key)); + if (missing.length > 0) { + throw new Error(`missing_permissions:${missing.join(',')}`); + } + + return map; + } + + private async ensurePosition( + manager: EntityManager, + seed: (typeof EDR_FREIGHT_POSITIONS)[number], + unitId: string, + organizationId: string, + ): Promise { + const positionRepository = manager.getRepository(Position); + + const existing = await positionRepository.findOne({ + where: { key: seed.key, unitId }, + select: { id: true }, + }); + + if (existing) { + return existing.id as string; + } + + const inserted = await positionRepository.insert({ + key: seed.key, + name: { ...seed.name }, + rank: seed.rank, + unitId, + organizationId, + }); + + this.logger.log(`Seeded freight position '${seed.key}'`); + + return inserted.identifiers[0]?.id as string; + } + + private async ensurePositionPermissions( + manager: EntityManager, + positionId: string, + seed: (typeof EDR_FREIGHT_POSITIONS)[number], + permissionKeyToId: Map, + ) { + const positionPermissionRepository = + manager.getRepository(PositionPermission); + + const existing = await positionPermissionRepository.find({ + where: { positionId }, + select: { permissionId: true }, + }); + const existingPermissionIds = new Set( + existing.map((row) => row.permissionId), + ); + + const rowsToInsert = seed.permissionKeys + .map((key) => permissionKeyToId.get(key) as string) + .filter((permissionId) => !existingPermissionIds.has(permissionId)) + .map((permissionId) => ({ positionId, permissionId })); + + if (rowsToInsert.length === 0) { + return; + } + + await positionPermissionRepository.insert(rowsToInsert); + + this.logger.log( + `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index a8c16ef05..6de3bc4de 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -16,7 +16,7 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; -const EDR_UNIT_KEY = 'edr_freight_hq'; +const EDR_UNIT_KEY = 'edr_freight_app'; // roleKey is kept only for backwards compatibility with existing UserRole rows; // access is granted via the assigned position (positionKey) + PositionPermission. diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 1b2960b8f..7d1fff022 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -18,6 +18,7 @@ const statusColorMap: Record = { EXPIRED: "red", PAID: "edr-green", IN_TRANSIT: "cyan", + ARRIVED: "teal", COMPLETED: "indigo", REJECTED: "red", CANCELLED: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index 36b18bbec..f2383b649 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -630,6 +630,20 @@ function DocReviewCard({ )} + {hasFile && ( + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 75e901d10..6256bd9cc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -727,9 +727,9 @@ function ImportT1UploadStep({ ); } - const departed = Boolean(t1.trainDepartedAt); - const canUpload = - canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed; + // Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia + // closes/accepts the T1. + const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed; return ( @@ -769,10 +769,6 @@ function ImportT1UploadStep({ pendingLabel="Waiting for the gate pass to be secured on the train schedule." doneLabel="" /> - ) : departed ? ( - }> - The train has departed — T1 documents are locked and can no longer be changed. - ) : uploaded.length === 0 && !canUpload ? ( = { APPROVED: "cyan", PAID: "edr-green", IN_TRANSIT: "blue", + ARRIVED: "teal", COMPLETED: "indigo", REJECTED: "red", CANCELLED: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index de010e380..887b235c4 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -33,7 +33,9 @@ const FleetRecordActions = ({ const isVehicle = config.slug === "vehicles"; const showHistory = Boolean(onHistory) && - (config.slug === "drivers" || config.slug === "vehicles"); + (config.slug === "drivers" || + config.slug === "vehicles" || + config.slug === "wagons"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx new file mode 100644 index 000000000..e2713fdcb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/WagonMovementHistoryModal.tsx @@ -0,0 +1,133 @@ +import type { ReactNode } from "react"; +import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react"; + +import { api } from "@/services/api"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; +import type { WagonMovementRecord } from "@/services/wagon.service"; + +export interface WagonMovementHistoryModalProps { + opened: boolean; + onClose: () => void; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +/** Chip style per wagon_movements ledger kind. */ +const KIND_META: Record = { + LOADED: { + label: "Loaded leg", + color: "edr-green", + icon: , + }, + EMPTY_REPOSITION: { + label: "Empty reposition", + color: "blue", + icon: , + }, + MANUAL: { + label: "Manual move", + color: "orange", + icon: , + }, +}; + +const yardLabel = ( + yard: { label?: string; code?: string } | null | undefined, + yardId: string | null, +) => yard?.label ?? yard?.code ?? yardId ?? "Unknown"; + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +/** + * Movement ledger for one wagon: every relocation between yards — booking legs, + * empty reposition rides, and manual staff corrections — newest first. + */ +const WagonMovementHistoryModal = ({ + opened, + onClose, + record, +}: WagonMovementHistoryModalProps) => { + const r = asObj(record); + const id = r.id ? String(r.id) : ""; + const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : ""; + + const { data, isLoading } = useQuery( + api.wagons.movements.queryOptions({ + input: { id }, + enabled: opened && Boolean(id), + }), + ); + + const movements: WagonMovementRecord[] = data ?? []; + + return ( + {`Wagon history — ${wagonNumber}`.trim()}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : movements.length === 0 ? ( + + No movements recorded yet. Every yard-to-yard move appears here — a + booking's loaded leg, an empty reposition ride, or a manual correction. + + ) : ( + + {movements.map((movement) => { + const meta = KIND_META[movement.kind] ?? { + label: movement.kind, + color: "gray", + icon: , + }; + const from = yardLabel(movement.fromYard, movement.fromYardId); + const to = yardLabel(movement.toYard, movement.toYardId); + return ( + + + {from} + + + + {to} + + + {meta.label} + + + } + > + {movement.note && ( + + {movement.note} + + )} + + {fmt(movement.occurredAt)} + + + ); + })} + + )} +
+ ); +}; + +export default WagonMovementHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx new file mode 100644 index 000000000..76ccdfde2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/YardWorkPanel.tsx @@ -0,0 +1,333 @@ +import { + Alert, + Badge, + Button, + Divider, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; +import { Freight } from "@edr/types"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling"; + +const parseError = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(message)) return message.join("; "); + return message || (error as Error)?.message || fallback; +}; + +const fmtDate = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const DIRECTION_COLORS: Record = { + IMPORT: "blue", + EXPORT: "teal", + DOMESTIC: "violet", +}; + +/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */ +const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS; + +function DirectionChip({ direction }: { direction: string }) { + return ( + + {DIRECTION_LABELS[direction] ?? direction} + + ); +} + +function BookingCell({ row }: { row: YardWorkBookingRow }) { + return ( + + + {row.reference ?? row.id.slice(0, 8)} + + {row.isGovernment && ( + + GOV + + )} + + ); +} + +function WorkTable({ + rows, + side, + trainHere, + onLoad, + onUnload, + pendingBookingId, +}: { + rows: YardWorkBookingRow[]; + side: "load" | "unload"; + trainHere: boolean; + onLoad: (bookingId: string) => void; + onUnload: (bookingId: string) => void; + pendingBookingId: string | null; +}) { + if (rows.length === 0) { + return ( + + {side === "load" ? "No bookings board here." : "No bookings alight here."} + + ); + } + return ( + + + + + Booking + Customer + Direction + Status + {side === "load" ? "Loaded" : "Arrived"} + + + + + {rows.map((row) => { + const timestamp = side === "load" ? row.loadedAt : row.arrivedAt; + const canAct = side === "load" ? row.canLoad : row.canUnload; + return ( + + + + + + {row.customer} + + + + + + + + + {timestamp ? ( + + {fmtDate(timestamp)} + + ) : ( + + — + + )} + + + + {side === "load" ? ( + + + + ) : ( + + + + )} + + + + ); + })} + +
+
+ ); +} + +/** + * Per-yard load/unload worklist for one schedule — every trade direction. Each + * booking boards at its origin yard and alights at its destination yard; the + * operator confirms both while the train's last recorded checkpoint is at that + * yard (the server validates the position). Unloading stamps the booking's own + * arrival — ARRIVED for import/export, COMPLETED for intercity. + */ +export function YardWorkPanel({ scheduleId }: { scheduleId: string }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const yardWorkQuery = useQuery( + api.trainScheduling.yardWork.queryOptions({ + input: { scheduleId }, + refetchInterval: 60_000, + }), + ); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }), + }); + + const load = useMutation( + api.trainScheduling.loadScheduleBooking.mutationOptions({ + onSuccess: () => { + void invalidate(); + toast({ title: "Cargo loaded" }); + }, + onError: (err) => + toast({ + title: "Load failed", + description: parseError(err, "Could not confirm loading"), + variant: "destructive", + }), + }), + ); + + const unload = useMutation( + api.trainScheduling.unloadScheduleBooking.mutationOptions({ + onSuccess: (result) => { + void invalidate(); + toast({ + title: + result.status === "COMPLETED" + ? "Cargo unloaded — booking completed" + : "Cargo unloaded — booking arrived", + }); + }, + onError: (err) => + toast({ + title: "Unload failed", + description: parseError(err, "Could not confirm unloading"), + variant: "destructive", + }), + }), + ); + + const data = yardWorkQuery.data; + const yards: YardWorkYard[] = data?.yards ?? []; + const trainAtYardId = data?.trainAtYardId ?? null; + const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null; + const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null; + + return ( + + + + + Yard load / unload + + + {yardWorkQuery.isLoading ? ( + + + + Loading yard worklists… + + + ) : yardWorkQuery.isError ? ( + }> + {parseError(yardWorkQuery.error, "Could not load the yard worklist")} + + ) : yards.length === 0 ? ( + + No bookings are assigned to this schedule yet. + + ) : ( + <> + + What boards and alights at each stop. Confirm loading at a booking's + origin and unloading at its destination while the train is at that + yard — unloading stamps the booking's own arrival, even before the + train's final stop. + + {yards.map((yard, index) => { + const trainHere = trainAtYardId === yard.yardId; + return ( + + {index > 0 && } + + {yard.yard} + {trainHere && ( + } + > + Train here + + )} + + + + Board here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingLoadId} + /> + + + + Alight here + + load.mutate({ scheduleId, bookingId })} + onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })} + pendingBookingId={pendingUnloadId} + /> + + + ); + })} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index a42c7c01b..05bb06372 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -326,6 +326,11 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`, + BOOKING_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/load`, + BOOKING_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`, INTERCITY_CANDIDATES: (id: string) => `/train-scheduling/schedules/${id}/intercity-candidates`, INTERCITY_ACCEPT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 127c8e708..25f4b51bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record = { label: "In Transit", color: "bg-sky-50 text-sky-700 border-sky-200", }, + ARRIVED: { + label: "Arrived", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, COMPLETED: { label: "Completed", color: "bg-indigo-50 text-indigo-700 border-indigo-200", @@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record = { color: "text-sky-600", stage: 4, }, + ARRIVED: { + title: "Arrived", + description: "Cargo unloaded at its destination yard.", + color: "text-emerald-600", + stage: 4, + }, COMPLETED: { title: "Completed", description: "Booking fulfilled.", @@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [ { key: "operations", label: "Operations", - statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"], }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, @@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [ }, { label: "Operations", - statuses: ["PAID", "IN_TRANSIT"], + statuses: ["PAID", "IN_TRANSIT", "ARRIVED"], }, { label: "Done", statuses: ["COMPLETED"] }, ] as const; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index f7bee5b41..820424283 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; +import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -585,12 +586,20 @@ const FleetResourcePage = () => {
- setHistoryTarget(null)} - entity={slug === "vehicles" ? "vehicle" : "driver"} - record={historyTarget} - /> + {slug === "wagons" ? ( + setHistoryTarget(null)} + record={historyTarget} + /> + ) : ( + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> + )} ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 2014f9c65..20b632710 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [ { label: "Both", value: "BOTH" }, ]; +// Mirrors the YardCountry enum in @edr/types — the only two countries on the line. +const YARD_COUNTRIES = [ + { label: "Ethiopia", value: "Ethiopia" }, + { label: "Djibouti", value: "Djibouti" }, +]; + const APPROVAL_ROLES = [ { label: "Line staff", value: "LINE_STAFF" }, { label: "Director", value: "DIRECTOR" }, @@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, - { name: "country", label: "Country", type: "text", required: true }, + { + name: "country", + label: "Country", + type: "select", + required: true, + options: YARD_COUNTRIES, + }, { name: "isActive", label: "Active", type: "boolean" }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 7ee0f7896..ca2b7c39e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -49,6 +49,7 @@ import { import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; +import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? : null} {scheduleId ? ( TRAIN_SCHEDULING_INVALIDATIONS, ), + yardWork: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").YardWorkResult + >( + "train-scheduling", + "yard-work", + ({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId), + ({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId], + ), + + loadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingLoadResult + >( + "train-scheduling", + "booking-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadScheduleBooking: endpoint< + { scheduleId: string; bookingId: string }, + import("@/types/trainScheduling").BookingUnloadResult + >( + "train-scheduling", + "booking-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + intercityCandidates: endpoint< { scheduleId: string }, import("@/types/trainScheduling").IntercityCandidatesResult @@ -1493,6 +1528,13 @@ export const api = { wagonService.getById(id).then((r) => r.data), ), + movements: endpoint<{ id: string }, WagonMovementRecord[]>( + "wagons", + "movements", + ({ id }) => wagonService.getMovements(id).then((r) => r.data), + ({ id }) => ["wagons", "movements", id], + ), + assignToTrain: endpoint< { wagonId: string; trainId: string; sequenceNumber?: number }, Wagon diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 3f4c6822f..be05c6d8c 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -8,6 +8,8 @@ import type { BookableSchedule, BookingWindow, AssignBookingsPayload, + BookingLoadResult, + BookingUnloadResult, CompositionRemovalEntry, UnassignedBookingsResponse, CreateTrainSchedulePayload, @@ -35,6 +37,7 @@ import type { UploadImportDjiboutiDocumentPayload, WagonAllocationAttemptResult, YardOption, + YardWorkResult, } from "@/types/trainScheduling"; interface BookingReferenceDataResponse { @@ -330,6 +333,35 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getYardWork: async (scheduleId: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId), + ); + return unwrap(response.data); + }, + + loadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + + unloadScheduleBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId), + {}, + ); + return unwrap(response.data); + }, + getIntercityCandidates: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index da7b2b509..a200195e0 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -37,6 +37,27 @@ export interface WagonListFilters { trainId?: string; } +/** + * One row of the wagon_movements ledger: every physical relocation between + * yards — a booking's loaded leg, an empty reposition ride, or a manual staff + * correction. Returned newest first by the API. + */ +export interface WagonMovementRecord { + id: string; + wagonId: string; + fromYardId: string | null; + toYardId: string; + fromYard?: { id?: string; label?: string; code?: string } | null; + toYard?: { id?: string; label?: string; code?: string } | null; + trainScheduleId: string | null; + bookingId: string | null; + kind: Freight.WagonMovementKind; + movedByUserId: string | null; + occurredAt: string; + note: string | null; + createdAt: string; +} + export const wagonService = { getAll: (filters: WagonListFilters = {}) => { const params = new URLSearchParams(); @@ -49,6 +70,8 @@ export const wagonService = { return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); }, getById: (id: string) => apiClient.get(`/wagons/${id}`), + getMovements: (id: string) => + apiClient.get(`/wagons/${id}/movements`), getByTrain: (trainId: string) => apiClient.get(`/wagons?trainId=${trainId}`), assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) => apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 2c0be4726..41c4a155a 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [ "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "REJECTED", "CANCELLED", diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index a67f1732f..e92d75c89 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -118,6 +118,7 @@ export type CustomerBookingStatus = | "APPROVED" | "PAID" | "IN_TRANSIT" + | "ARRIVED" | "COMPLETED" | "REJECTED" | "CANCELLED"; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 395a8c5de..5e3ab1b0e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -796,3 +796,52 @@ export interface IntercityAcceptResult { rejected: Array<{ bookingId: string; reason: string }>; remaining: IntercityCapacity; } + +// ── Yard load / unload worklist ────────────────────────────────────────────── +// Per-booking journey along the train's corridor: every booking boards at its +// origin yard and alights at its destination yard, confirmed by the yard +// operator while the train's latest checkpoint is at that yard. + +export interface YardWorkBookingRow { + id: string; + reference: string | null; + status: string; + tradeDirection: string; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + loadedAt: string | null; + arrivedAt: string | null; + canLoad: boolean; + canUnload: boolean; +} + +export interface YardWorkYard { + yardId: string; + yard: string; + toLoad: YardWorkBookingRow[]; + toUnload: YardWorkBookingRow[]; +} + +export interface YardWorkResult { + scheduleId: string; + scheduleStatus: string; + trainAtYardId: string | null; + yards: YardWorkYard[]; +} + +export interface BookingLoadResult { + bookingId: string; + status: string; + loadedAt: string; +} + +export interface BookingUnloadResult { + bookingId: string; + /** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */ + status: string; + arrivedAt: string; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx index a65aa009e..ea06ae3e5 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActivityRow.tsx @@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({ const verb = booking.status === "IN_TRANSIT" ? "departed" - : booking.status === "COMPLETED" - ? "delivered" - : booking.status === "PENDING_APPROVAL" - ? "quote ready" - : booking.status === "SUBMITTED" - ? "submitted for review" - : "created"; + : booking.status === "ARRIVED" + ? "arrived" + : booking.status === "COMPLETED" + ? "delivered" + : booking.status === "PENDING_APPROVAL" + ? "quote ready" + : booking.status === "SUBMITTED" + ? "submitted for review" + : "created"; return ( = { badgeDot: "edr-green.5", action: { label: "Track", kind: "outline", icon: MapPin }, }, + ARRIVED: { + stage: 3, + icon: MapPin, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Arrived at destination yard · awaiting release", + step: "edr-green.5", + badgeLabel: "Arrived", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Track", kind: "outline", icon: MapPin }, + }, COMPLETED: { stage: 4, icon: CheckCircle2, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 6d274f22f..40994d589 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -118,7 +118,9 @@ export function ReadonlyBookingView({ const canAssignCustomerTruck = booking.paymentStatus === "PAID" && usesCustomerTruck && - ["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status); + ["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes( + status, + ); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 127901186..13404f8f6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,10 +66,12 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); - // The Arrival stage has no booking status of its own — it lights up from the - // train's ARRIVED state, so the headline is overridden here. + // Legacy bookings never reach the ARRIVED status — they light up the Arrival + // stage from the train's ARRIVED state while staying IN_TRANSIT, so the + // headline is overridden here. Bookings with a per-booking journey carry the + // ARRIVED status themselves and use its own STATUS_MAP copy. const cfg = - stage === ARRIVAL_STAGE + stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE ? { title: "Train arrived at destination", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 2a41a6fa3..4b32a059c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [ statuses: ["EXPIRED", "IN_TRANSIT"], }, { - // No booking status maps here: the booking stays IN_TRANSIT until - // delivery, so this stage lights up from the assigned train's own status + // ARRIVED: cargo unloaded at the booking's own destination yard (segment + // corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so + // this stage also lights up from the assigned train's own status // (trainScheduleStatus === "ARRIVED") — see resolveStage. label: "Arrival", icon: MapPin, - statuses: [], + statuses: ["ARRIVED"], }, { label: "Complete", @@ -75,8 +76,10 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( /** * Stage for a booking, factoring in the assigned train's operational status: - * a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its - * train has ARRIVED the tracker advances to the Arrival stage. + * a booking with per-booking journey data reaches ARRIVED when it is unloaded + * at its own destination yard; a legacy booking is stuck at IN_TRANSIT between + * dispatch and delivery, so once its train has ARRIVED the tracker advances to + * the Arrival stage. */ export function resolveStage(booking: { status: string; @@ -177,6 +180,12 @@ export const STATUS_MAP: Record< description: "Your shipment is currently moving through the rail network.", stage: 6, }, + ARRIVED: { + title: "Arrived at destination", + description: + "Your cargo has been unloaded at its destination yard and is being prepared for release.", + stage: 7, + }, OPERATION_REQUEST_PENDING: { title: "Operation request under review", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 4a7643377..bd68632f7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -58,6 +58,7 @@ import { const TRACKABLE_STATUSES = new Set([ "PAID", "IN_TRANSIT", + "ARRIVED", "COMPLETED", "DELIVERED", ]); @@ -83,7 +84,7 @@ const STATUS_FILTERS = [ statuses: "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", }, - { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, + { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx index 64ee63d5e..4282c7e81 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/tracking/ShipmentTrackingModal.tsx @@ -6,6 +6,7 @@ import { Clock, Flag, MapPin, + PackageCheck, PackageX, RefreshCw, Train, @@ -15,11 +16,14 @@ import { api } from "@/services/api"; import { Freight } from "@edr/types"; import { + bookingJourneyState, + bookingLegRange, + bookingShipmentStatusLabel, checkpointKindLabel, corridorProgress, isArrived, isDispatched, - shipmentStatusLabel, + type BookingJourneyState, } from "./trackingStages"; const GREEN = "#0EA371"; @@ -70,6 +74,7 @@ export function ShipmentTrackingModal({ trainNumber={data?.trainNumber ?? null} status={data?.scheduleStatus ?? null} currentSequenceNo={data?.currentSequenceNo ?? -1} + journey={data ? bookingJourneyState(data) : null} onClose={onClose} onRefresh={() => refetch()} refreshing={isFetching} @@ -95,6 +100,7 @@ export function ShipmentTrackingModal({ ) : data ? ( + @@ -111,6 +117,7 @@ function Header({ trainNumber, status, currentSequenceNo, + journey, onClose, onRefresh, refreshing, @@ -119,6 +126,7 @@ function Header({ trainNumber: string | null; status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; onClose: () => void; onRefresh: () => void; refreshing: boolean; @@ -171,7 +179,11 @@ function Header({ - + @@ -223,12 +235,17 @@ function IconButton({ function HeaderStatusPill({ status, currentSequenceNo, + journey, }: { status: Freight.TrainScheduleStatus | null; currentSequenceNo: number; + journey: BookingJourneyState; }) { - const arrived = isArrived(status); - const moving = isDispatched(status); + // The booking's own journey wins: a sub-corridor booking can be unloaded + // (arrived) at its own yard while the train is still moving. + const arrived = journey === "arrived" || (!journey && isArrived(status)); + const moving = !arrived && (journey === "in-transit" || isDispatched(status)); + const label = bookingShipmentStatusLabel(journey, status, currentSequenceNo); const bg = arrived ? "rgba(14,163,113,0.22)" : moving @@ -254,7 +271,7 @@ function HeaderStatusPill({ }} /> - {shipmentStatusLabel(status, currentSequenceNo)} + {label} ); @@ -263,7 +280,10 @@ function HeaderStatusPill({ // ── Summary bar (ETA / departure / arrival) ──────────────────────────────────── function SummaryBar({ data }: { data: Freight.IBookingTracking }) { - const arrived = isArrived(data.scheduleStatus); + const journey = bookingJourneyState(data); + // Booking-level arrival (unloaded at its own destination yard) counts as + // arrived even while the train itself is still moving down the corridor. + const arrived = journey === "arrived" || isArrived(data.scheduleStatus); const items: Array<{ label: string; value: string; accent?: boolean }> = [ { label: "Departed", @@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { }, { label: arrived ? "Arrived" : "Est. arrival", - value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt), + value: fmtTime( + (journey === "arrived" ? data.arrivedAt : null) ?? + data.actualArrivalAt ?? + data.scheduledArrivalAt, + ), accent: !arrived, }, { @@ -317,6 +341,66 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) { ); } +// ── Per-booking journey line (loaded / unloaded at the booking's own yards) ──── + +function BookingJourneyLine({ data }: { data: Freight.IBookingTracking }) { + if (!data.loadedAt && !data.arrivedAt) return null; + + const stationLabel = (yardId?: string | null) => + data.stations.find((s) => s.yardId === yardId)?.label ?? null; + const origin = stationLabel(data.bookingOriginYardId) ?? "origin yard"; + const destination = + stationLabel(data.bookingDestinationYardId) ?? "destination yard"; + + return ( + + {data.loadedAt && ( + } + text={`Loaded at ${origin}`} + time={fmtTime(data.loadedAt)} + /> + )} + {data.arrivedAt && ( + } + text={`Arrived at ${destination}`} + time={fmtTime(data.arrivedAt)} + /> + )} + + ); +} + +function JourneyChip({ + icon, + text, + time, +}: { + icon: React.ReactNode; + text: string; + time: string; +}) { + return ( + + {icon} + + {text} + + + · {time} + + + ); +} + // ── Corridor: stations + train marker ────────────────────────────────────────── function Corridor({ data }: { data: Freight.IBookingTracking }) { @@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const current = data.currentSequenceNo; const progress = corridorProgress(stations.length, current, arrived); + // The booking's own leg on the corridor (sub-corridor bookings ride only a + // slice of the train's route). Stations outside the leg render dimmed. + const leg = bookingLegRange( + stations, + data.bookingOriginYardId, + data.bookingDestinationYardId, + ); + // Map sequenceNo → latest checkpoint at that station for captions. const checkpointBySeq = new Map(); for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c); @@ -415,6 +507,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) { const reached = arrived || (current >= 0 && i <= current); const isCurrent = !arrived && i === current; const isLast = i === stations.length - 1; + const onLeg = !leg || (i >= leg.start && i <= leg.end); const cp = checkpointBySeq.get(s.sequenceNo); return ( @@ -441,6 +535,7 @@ function StationNode({ isCurrent, isEndpoint, arrivedHere, + dimmed, time, align, }: { @@ -449,6 +544,8 @@ function StationNode({ isCurrent: boolean; isEndpoint: boolean; arrivedHere: boolean; + /** Station lies outside the booking's own leg — render muted. */ + dimmed: boolean; time: string | null; align: "left" | "center" | "right"; }) { @@ -462,6 +559,7 @@ function StationNode({ flex: isEndpoint ? "0 0 auto" : 1, minWidth: 0, maxWidth: 120, + opacity: dimmed ? 0.4 : 1, }} > s.yardId === originYardId); + const end = stations.findIndex((s) => s.yardId === destinationYardId); + if (start < 0 || end < 0) return null; + return start <= end ? { start, end } : { start: end, end: start }; +} + /** Caption for a checkpoint kind. */ export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string { switch (kind) { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index 0c63f1b95..f9b3c3420 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -13,12 +13,15 @@ import { import { ArrowRight, CalendarClock, + CheckCircle2, ChevronLeft, ChevronRight, + Clock, } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; +import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window"; const INK = "#10202F"; const MUTED = "#6B7C8E"; @@ -209,6 +212,65 @@ function WindowCard({ w }: { w: MyBookingWindow }) { ); } +/** + * One-line status strip above the cards: green when a window is open right now + * (the customer can act), neutral with the next opening time otherwise. + */ +function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) { + const open = windows.find((w) => w.isOpenNow); + if (open) { + const lane = + open.origin && open.destination + ? ` on ${open.origin} → ${open.destination}` + : ""; + return ( + + + + A booking window is open right now{lane} — you can create a shipment + booking before it closes. + + + ); + } + + const next = soonestUpcomingWindow(windows); + return ( + + + + {next?.windowOpensAt + ? `Booking is not open yet — the next window opens ${formatWindowOpensAt( + next.windowOpensAt, + )} EAT.` + : "Booking is not open right now. You'll see the opening time here once a window is announced."} + + + ); +} + interface ContractBookingWindowsSectionProps { /** Windows already scoped to this contract's routes/direction by the API. */ windows: MyBookingWindow[]; @@ -248,8 +310,6 @@ export function ContractBookingWindowsSection({ safePage * PER_PAGE + PER_PAGE, ); - if (!isLoading && sorted.length === 0) return null; - return ( @@ -313,12 +373,39 @@ export function ContractBookingWindowsSection({ ))} + ) : sorted.length === 0 ? ( + + + + No booking windows announced yet + + + When a train is scheduled on this contract's routes, its + booking window will appear here with the opening time. + + ) : ( - - {visible.map((w) => ( - - ))} - + <> + + + {visible.map((w) => ( + + ))} + + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index c3c7710e6..24991d94a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -507,23 +507,17 @@ export default function NewContractPage({ const isContainer = data.cargoType === "container"; // Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled - // size; bulk: a single commodity row. - // GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not. - const isGeneral = data.contractKind === "general_contract"; + // size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped + // (quantityCap omitted → NULL): the customer books repeatedly against a + // GENERAL contract until its validity expires. const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer ? data.enabledContainerSizes.map((size) => ({ containerSize: size, - quantityCap: - isGeneral && data.containerSizeCaps[size] - ? data.containerSizeCaps[size] - : undefined, })) : [ { cargoTypeId: data.cargoTypePath?.[1] || undefined, cargoFreeText: data.cargoFreeText || undefined, - quantityCap: - isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined, }, ]; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 4d0dd3564..565ca2079 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record< PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning }, PAID: { label: "Paid", ...TONE.success }, IN_TRANSIT: { label: "In Transit", ...TONE.info }, + ARRIVED: { label: "Arrived", ...TONE.success }, COMPLETED: { label: "Completed", ...TONE.success }, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx index 06f67f7fc..d53b64552 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx @@ -9,23 +9,27 @@ import { fieldStyles } from "./shared"; export function PaymentCurrencyField({ control, + etbOnly = false, }: { control: Control; + /** Intercity (domestic) contracts are priced in ETB only. */ + etbOnly?: boolean; }) { + const options = etbOnly + ? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB") + : PAYMENT_CURRENCY_OPTIONS; return ( { - const selected = PAYMENT_CURRENCY_OPTIONS.find( - (o) => o.value === field.value, - ); + const selected = options.find((o) => o.value === field.value); return (