diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index e10fb7e47..9561a7b73 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -8,7 +8,10 @@ import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; import { EventEmitterModule } from "@nestjs/event-emitter"; import { DataSource, DataSourceOptions } from "typeorm"; -import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; +import { + ensurePostgresSchemas, + APPLICATION_SEARCH_PATH, +} from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; @@ -48,6 +51,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"; @@ -108,7 +112,27 @@ import { LoggerMiddleware } from "./logger.middleware"; } await ensurePostgresSchemas(options as DataSourceOptions); const dataSource = new DataSource(options as DataSourceOptions); - return dataSource.initialize(); + await dataSource.initialize(); + + // The remote edr_dev DB sits behind a connection pooler/proxy that rejects + // the Postgres `options` startup parameter (08P01). Instead of setting + // search_path at connect time, apply it per physical connection: the pg + // Pool emits `connect` for every new client (initial fill, pool growth, + // reconnect), so every backend session gets the schema search order. + const pool = (dataSource.driver as { master?: unknown }).master as + | { on?: (event: string, cb: (client: unknown) => void) => void } + | undefined; + if (pool?.on) { + pool.on("connect", (client) => { + (client as { query: (sql: string) => Promise }) + .query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`) + .catch(() => { + /* connection will be validated on first real query */ + }); + }); + } + + return dataSource; }, }), SharedAuthModule, @@ -165,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ], providers: [ EdrOrgSeeder, + FreightPositionsSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, PricingDataSeeder, @@ -188,6 +213,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, @@ -209,6 +235,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/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 9e05eb794..dcf09fbd9 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -44,7 +44,6 @@ import { NotificationTemplate, } from "@tria-plc/iamapi-common"; import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; -import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas"; const iamEntities = [ DefaultPosition, @@ -105,14 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => { password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", schema: "public", - // The `-c search_path=...` startup option is rejected by transaction-pooling - // poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When - // behind such a pooler set DB_PGBOUNCER=true and instead make the search_path - // a role default: ALTER ROLE IN DATABASE SET search_path TO - // public,iam,freight,audit; - ...(process.env.DB_PGBOUNCER === "true" - ? {} - : { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }), + // NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the + // Postgres startup `options` parameter, which connection poolers (PgBouncer / + // proxies fronting the remote edr_dev DB) reject with + // `08P01 unsupported startup parameter in options: search_path`. + // The search_path is instead applied per-connection via a pool `connect` + // handler in app.module.ts (see setPoolSearchPath). entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], autoLoadEntities: true, migrations: [ 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/migrations/2000000000000-AddCustomsPriorityConfig.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts new file mode 100644 index 000000000..c7009c303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Moves service-level priority off the service_types table and onto the + * admin-managed priority_configs table as a new CUSTOMS rule type. + * + * - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs). + * - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be + * null, same as WAGON). + * - Seeds the two customs wagon-count tiers: 1–10 → 7 pts, 11–53 → 15 pts. + * CUSTOMS rules apply only when the booking's service type includesCustoms. + */ +export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points; + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS priority_configs_type_check; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT priority_configs_type_check + CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS')); + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS chk_currency_for_type; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT chk_currency_for_type CHECK ( + (type = 'WAGON' AND currency IS NULL) OR + (type = 'CURRENCY' AND currency IS NOT NULL) OR + (type = 'CUSTOMS' AND currency IS NULL) + ); + `); + + await queryRunner.query(` + INSERT INTO freight.priority_configs + (type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order) + VALUES + ('CUSTOMS', 'With customs 1–10 wagons', NULL, 1, 10, 7, true, 1), + ('CUSTOMS', 'With customs 11–53 wagons', NULL, 11, 53, 15, true, 2); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS'; + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS chk_currency_for_type; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT chk_currency_for_type CHECK ( + (type = 'WAGON' AND currency IS NULL) OR + (type = 'CURRENCY' AND currency IS NOT NULL) + ); + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS priority_configs_type_check; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT priority_configs_type_check + CHECK (type IN ('WAGON', 'CURRENCY')); + `); + + await queryRunner.query(` + ALTER TABLE freight.service_types + ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts new file mode 100644 index 000000000..cb6f7dc91 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds bookings.consolidation_resume_status: the status a booking parked in + * PENDING_CONSOLIDATION returns to once it pairs with a wagon partner. + * + * Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged). + * Contract-drawdown bookings (GL shipments) set it to the status + * createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or + * AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow + * instead of wrongly moving them to SUBMITTED. + */ +export class AddConsolidationResumeStatus2010000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS consolidation_resume_status; + `); + } +} 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.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 61dc78e13..48c5b628b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, BookingLifecycleNotifierService, + ConsolidationService, CustomerTruckService, ContainerReceiptService, ], 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..fa11fc66c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository { } /** - * Pair two bookings for consolidation. Both return to SUBMITTED so staff can - * accept them into the approval chain; the link itself (consolidationPartnerId) - * marks them as consolidated in the UI. + * Pair two bookings for consolidation. Each returns to its own resume status — + * SUBMITTED for a direct customer booking (so staff can accept it into the + * approval chain) or the stored consolidationResumeStatus for a contract + * drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself + * (consolidationPartnerId) marks them as consolidated in the UI. The resume + * status is cleared once used, so a later un-pair re-parks cleanly. */ async pairConsolidation(bookingId: string, partnerId: string): Promise { + const [booking, partner] = await Promise.all([ + this.repository.findOne({ + where: { id: bookingId }, + select: { id: true, consolidationResumeStatus: true }, + }), + this.repository.findOne({ + where: { id: partnerId }, + select: { id: true, consolidationResumeStatus: true }, + }), + ]); + await this.repository.update(bookingId, { consolidationPartnerId: partnerId, - status: 'SUBMITTED', + status: booking?.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, } as never); await this.repository.update(partnerId, { consolidationPartnerId: bookingId, - status: 'SUBMITTED', + status: partner?.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, } as never); } - /** Park a booking that needs consolidation but has no partner yet. */ - async parkForConsolidation(bookingId: string): Promise { + /** + * Park a booking that needs consolidation but has no partner yet. The optional + * resumeStatus is where the booking returns once it pairs — pass it for a + * contract drawdown so pairing resumes the contract-booking flow rather than + * the direct-booking SUBMITTED default. + */ + async parkForConsolidation( + bookingId: string, + resumeStatus?: string | null, + ): Promise { await this.repository.update(bookingId, { consolidationPartnerId: null, status: 'PENDING_CONSOLIDATION', + consolidationResumeStatus: resumeStatus ?? null, } as never); } @@ -1019,6 +1044,81 @@ 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(); + } + + /** + * Commercial bookings on the day's corridor whose operation request was NOT + * accepted by staff (still pending / changes / price-confirm) and are not yet + * linked to a train. These never reached FULLY_EXECUTED, so they never enter the + * batch pool; the window's doc-review end sweeps them to EXPIRED. Government + * bookings are excluded (they don't go through the customer window). + */ + findUnacceptedForRouteDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .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') + .andWhere( + `booking.status IN ( + 'OPERATION_REQUESTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'OPERATION_PRICE_PENDING_CONFIRM' + )`, + ) + .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..a5f25ad21 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -24,6 +24,7 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { InjectDataSource } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -99,6 +100,7 @@ export class BookingsService { private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, private readonly contractPdfService: ContractPdfService, + private readonly events: EventEmitter2, ) {} async assignCustomerTruck( @@ -493,6 +495,14 @@ export class BookingsService { messages.push( this.consolidationService.describePaired(partner.reference, slots), ); + // Let deferred owners (e.g. contract drawdowns whose invoice/milestones + // were held while the booking waited) finalize now that a whole wagon + // exists. Fire-and-forget: a listener failure must not undo the pairing. + this.events + .emitAsync('booking.consolidation.paired', { + bookingIds: [booking.id, partner.id], + }) + .catch(() => undefined); return { booking: paired, messages }; } @@ -605,10 +615,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 +1293,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 +1318,7 @@ export class BookingsService { actualArrivalAt: null, scheduledDepartureAt: null, scheduledArrivalAt: null, + ...journey, }; if (!booking.trainScheduleId) { @@ -1331,6 +1355,7 @@ export class BookingsService { actualArrivalAt: track.actualArrivalAt, scheduledDepartureAt: track.scheduledDepartureAt, scheduledArrivalAt: track.scheduledArrivalAt, + ...journey, }; } @@ -1607,7 +1632,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..398c64809 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', @@ -430,6 +431,14 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'consolidation_partner_id' }) consolidationPartner?: Booking | null; + // Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs. + // Null for direct customer bookings (they resume to SUBMITTED, the historical + // default); contract-drawdown bookings set it to the status createUnderContract + // would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so + // pairing resumes them into the right flow instead of the direct-booking one. + @Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true }) + consolidationResumeStatus?: string | null; + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; @@ -458,6 +467,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/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts new file mode 100644 index 000000000..a83837350 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -0,0 +1,181 @@ +import { ContractBookingService } from './contract-booking.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The GL contract-drawdown path must run wagon consolidation before invoicing. + * A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in + * PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it + * pairs with a wagon partner. These tests exercise the two new hooks directly. + */ +describe('ContractBookingService — drawdown consolidation gate', () => { + function makeService(overrides: { + consolidationService?: Partial>; + bookingsRepository?: Partial>; + invoiceService?: Partial>; + milestoneService?: Partial>; + contractsRepository?: Partial>; + }) { + const consolidationService = { + slotsFromBooking: jest.fn().mockResolvedValue([]), + describePaired: jest.fn().mockReturnValue('paired'), + describePending: jest.fn().mockReturnValue('pending'), + needsConsolidationFromBooking: jest.fn().mockResolvedValue(false), + ...overrides.consolidationService, + }; + const bookingsRepository = { + findConsolidationPartner: jest.fn().mockResolvedValue(null), + pairConsolidation: jest.fn().mockResolvedValue(undefined), + parkForConsolidation: jest.fn().mockResolvedValue(undefined), + findByIdWithFiles: jest.fn(), + ...overrides.bookingsRepository, + }; + const invoiceService = { + ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }), + ...overrides.invoiceService, + }; + const milestoneService = { + seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), + seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), + ...overrides.milestoneService, + }; + const contractsRepository = { + findByIdWithRelations: jest.fn(), + currentCycle: jest.fn().mockResolvedValue(null), + linkBooking: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + ...overrides.contractsRepository, + }; + + const service = new ContractBookingService( + contractsRepository as never, + bookingsRepository as never, + {} as never, // bookingPricingService + consolidationService as never, + {} as never, // containerTypesService + {} as never, // ruleEngineService + milestoneService as never, + {} as never, // workflowService + invoiceService as never, + {} as never, // dataSource + {} as never, // trainSchedulingService + ); + return { + service, + consolidationService, + bookingsRepository, + invoiceService, + milestoneService, + contractsRepository, + }; + } + + const booking = { id: 'b-1', reference: 'BK-1' } as Booking; + + it('parks (not pairs) when no complementary partner exists', async () => { + const { service, bookingsRepository } = makeService({ + consolidationService: { + slotsFromBooking: jest + .fn() + .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), + }, + bookingsRepository: { + findConsolidationPartner: jest.fn().mockResolvedValue(null), + }, + }); + + const result = await (service as never as { + consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; + }).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING'); + + expect(result.paired).toBe(false); + expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith( + 'b-1', + 'OPERATION_REQUEST_PENDING', + ); + expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled(); + }); + + it('pairs when a complementary partner exists', async () => { + const { service, bookingsRepository } = makeService({ + consolidationService: { + slotsFromBooking: jest + .fn() + .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), + }, + bookingsRepository: { + findConsolidationPartner: jest + .fn() + .mockResolvedValue({ id: 'p-1', reference: 'BK-2' }), + }, + }); + + const result = await (service as never as { + consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; + }).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS'); + + expect(result.paired).toBe(true); + expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1'); + expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled(); + }); + + it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => { + const paired = { + id: 'b-1', + reference: 'BK-1', + contractId: 'c-1', + status: 'OPERATION_REQUEST_PENDING', + } as Booking; + const contract = { + id: 'c-1', + contractKind: 'GENERAL', + customsClearingEnabled: true, + tradeDirection: 'EXPORT', + }; + const { service, invoiceService, milestoneService } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(paired), + }, + contractsRepository: { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['b-1'] }); + + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // GENERAL customs → per-booking pre + post milestones. + expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled(); + expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled(); + }); + + it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { + const stillPending = { + id: 'b-1', + contractId: 'c-1', + status: 'PENDING_CONSOLIDATION', + } as Booking; + const { service, invoiceService } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(stillPending), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['b-1'] }); + + expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); + }); + + it('onConsolidationPaired ignores a non-contract (direct) booking', async () => { + const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking; + const { service, invoiceService, contractsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(direct), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['d-1'] }); + + expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled(); + expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 5e80b1301..062bbd5d0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -8,6 +8,7 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; import { insertWithGeneratedReference } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; @@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; @@ -61,6 +63,7 @@ export class ContractBookingService { private readonly contractsRepository: ContractsRepository, private readonly bookingsRepository: BookingsRepository, private readonly bookingPricingService: BookingPricingService, + private readonly consolidationService: ConsolidationService, private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, @@ -232,6 +235,105 @@ export class ContractBookingService { warnings.push(...computed.warnings); } + // Wagon consolidation gate. A container drawdown whose lines leave a partial + // wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner + // before it can ship. Direct bookings do this at submit; drawdowns have no + // submit step, so we run it here — BEFORE invoicing/milestones. When it parks + // for a partner the booking is NOT invoiced or scheduled: those steps run + // later in finalizeContractBooking, triggered by the pairing event. When it + // pairs (or needs no consolidation) we finalize inline. + const withContainers = await this.bookingsRepository.findByIdWithFiles( + booking.id, + ); + const intendedStatus = generalCustoms + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; + if ( + withContainers && + freightType === 'CONTAINER' && + (await this.consolidationService.needsConsolidationFromBooking( + withContainers, + )) + ) { + const parked = await this.consolidateDrawdown( + withContainers, + intendedStatus, + ); + warnings.push(parked.message); + if (!parked.paired) { + // Waiting for a partner — stop here. The booking sits in + // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. + const pendingResult = await this.bookingsRepository.findByIdWithFiles( + booking.id, + ); + return { booking: pendingResult ?? booking, warnings }; + } + } + + await this.finalizeContractBooking( + booking.id, + contract, + generalCustoms, + ); + + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: result ?? booking, warnings }; + } + + /** + * Search for a complementary partner for a parked-eligible drawdown, pair it or + * park it in PENDING_CONSOLIDATION with the resume status it should return to. + * Pairing (via BookingsRepository.pairConsolidation) resumes both partners and + * emits booking.consolidation.paired, which finalizes any deferred contract + * booking. Returns whether a partner was found plus a customer-facing message. + */ + private async consolidateDrawdown( + booking: Booking, + resumeStatus: string, + ): Promise<{ paired: boolean; message: string }> { + const slots = await this.consolidationService.slotsFromBooking(booking); + if (!slots.length) { + return { paired: false, message: '' }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + return { + paired: true, + message: this.consolidationService.describePaired( + partner.reference, + slots, + ), + }; + } + + await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus); + return { + paired: false, + message: this.consolidationService.describePending(booking, slots), + }; + } + + /** + * Finalize a contract booking once it is cleared to proceed (needed no + * consolidation, or has just paired): seed clearance milestones / link the + * contract cycle, then generate the invoice. Idempotent — safe to call again + * for a booking that pairs after having waited. Skips a booking that is still + * PENDING_CONSOLIDATION (guards the pairing event against a stray partner). + */ + private async finalizeContractBooking( + bookingId: string, + contract: Contract, + generalCustoms: boolean, + ): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.status === 'PENDING_CONSOLIDATION') return; + // ONE_TIME customs (legacy contract-cycle path): link the contract clearance // cycle to this booking, seed post-booking milestones, and lock the contract // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle @@ -239,10 +341,10 @@ export class ContractBookingService { if (contract.customsClearingEnabled && !generalCustoms) { const cycle = await this.contractsRepository.currentCycle(contract.id); if (cycle) { - await this.contractsRepository.linkBooking(cycle.id, booking.id); + await this.contractsRepository.linkBooking(cycle.id, bookingId); } await this.milestoneService.seedPostBookingMilestones( - booking.id, + bookingId, contract.tradeDirection, ); await this.contractsRepository.update(contract.id, { @@ -252,24 +354,22 @@ export class ContractBookingService { } else if (generalCustoms) { // Per-booking clearance: seed full milestone timeline on the booking. await this.milestoneService.seedPreBookingMilestonesOnBooking( - booking.id, + bookingId, contract.tradeDirection, ); await this.milestoneService.seedPostBookingMilestones( - booking.id, + bookingId, contract.tradeDirection, ); } - const result = await this.bookingsRepository.findByIdWithFiles(booking.id); - // Contract bookings are born past the billable gate (the contract is already // executed), so the invoice is generated here — they never pass through the // legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings. // Idempotent and non-blocking: a billing hiccup must not undo the booking. // Skips silently when unbillable (no company / no priced amount). await this.invoiceService - .ensureInvoiceForBooking(result ?? booking) + .ensureInvoiceForBooking(booking) .catch((err) => this.logger.error( `Failed to generate invoice for contract booking ${booking.reference}: ${ @@ -277,8 +377,40 @@ export class ContractBookingService { }`, ), ); + } - return { booking: result ?? booking, warnings }; + /** + * A parked drawdown just paired — finalize whichever partner is a contract + * booking that was waiting (invoice + milestones deferred at creation). The + * pairing already resumed the booking's status from consolidationResumeStatus; + * this runs the create-time tail that was skipped. Non-contract partners have + * their own finalize path (staff accept) and are ignored here. + */ + @OnEvent('booking.consolidation.paired') + async onConsolidationPaired(payload: { + bookingIds: string[]; + }): Promise { + for (const id of payload.bookingIds ?? []) { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') { + continue; + } + const contract = await this.contractsRepository.findByIdWithRelations( + booking.contractId, + ); + if (!contract) continue; + const generalCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + await this.finalizeContractBooking(id, contract, generalCustoms).catch( + (err) => + this.logger.error( + `Failed to finalize paired contract booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } } /** 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 181d8b688..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 }; @@ -395,14 +399,20 @@ export class GlOperationsService { } if (!file) throw new BadRequestException('Attach the invoice document.'); + // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a + // DJ doc milestone that may never be recorded — once the Djibouti gate pass + // is secured. The invoice itself stays optional; nothing forces GL DJ to send one. const milestones = await this.milestoneService.listForBooking(bookingId); const offloaded = milestones.find( (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', ); if (!offloaded) { - throw new BadRequestException( - 'Cargo must be offloaded before the final invoice can be raised.', - ); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.', + ); + } } const existing = await this.billingService.findInvoice( 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/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index 022bbf767..f661bb662 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -7,6 +7,11 @@ import { import { BaseEntity } from "@edr/api-common"; @Entity({ + // Table lives in the freight schema like every other freight entity. Without + // this the entity inherits the DataSource default schema (public), so TypeORM + // queries public.otp_verifications — which doesn't exist — and OTP verify + // (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError. + schema: "freight", name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 36b863dd6..8dcc58e77 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -21,7 +21,7 @@ export class PriorityConfigsController { @ApiOperation({ summary: 'List priority configs' }) findAll(@Query() query: Record) { return this.service.findAll({ - type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined, + type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined, isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, page: query['page'] ? parseInt(query['page'], 10) : undefined, pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts index d2ca44d93..484d3fbaf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreatePriorityConfigDto { - @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) - @IsIn(['WAGON', 'CURRENCY']) - type!: 'WAGON' | 'CURRENCY'; + @ApiProperty({ + description: 'Config type: WAGON, CURRENCY, or CUSTOMS', + enum: ['WAGON', 'CURRENCY', 'CUSTOMS'], + }) + @IsIn(['WAGON', 'CURRENCY', 'CUSTOMS']) + type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) @IsString() @@ -12,7 +15,8 @@ export class CreatePriorityConfigDto { label!: string; @ApiPropertyOptional({ - description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON', + description: + 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS', maxLength: 5, }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index d68625fdc..a8e030bba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -32,17 +32,6 @@ export class CreateServiceTypeDto { @IsBoolean() includesCustoms?: boolean; - @ApiPropertyOptional({ - description: 'Priority bonus points awarded when this service is used (0–15)', - default: 0, - maximum: 15, - }) - @IsOptional() - @IsInt() - @Min(0) - @Max(15) - priorityBonusPoints?: number; - @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts index df60b3ea1..e1fa5bfa7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts @@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm'; @Index(['currency', 'type']) export class PriorityConfig extends BaseEntity { @Column({ name: 'type', type: 'varchar', length: 20 }) - type!: 'WAGON' | 'CURRENCY'; + type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @Column({ name: 'label', type: 'varchar', length: 100 }) label!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index 2b7cb3f23..b882f1a08 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity { @Column({ name: 'includes_customs', type: 'boolean', default: false }) includesCustoms!: boolean; - @Column({ name: 'priority_bonus_points', type: 'int', default: 0 }) - priorityBonusPoints!: number; - @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 0fee8e75a..e451098fc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -176,13 +176,12 @@ export class RuleEngineService { } const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); - if (serviceType) { - priorityScore += serviceType.priorityBonusPoints; - } + const includesCustoms = serviceType?.includesCustoms ?? false; // Additive priority blocks, each keyed on the booking's total wagon count: // - WAGON rules apply regardless of currency. // - CURRENCY rules apply only when the payment currency matches. + // - CUSTOMS rules apply only when the service type includes customs. const priorityConfigs = await this.priorityConfigsRepo.findAllActive(); const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) => input.totalWagons >= cfg.minWagonCount && @@ -191,7 +190,8 @@ export class RuleEngineService { for (const cfg of priorityConfigs) { const applies = cfg.type === 'WAGON' || - (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency); + (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) || + (cfg.type === 'CUSTOMS' && includesCustoms); if (applies && wagonsInRange(cfg)) { priorityScore += cfg.scorePoints; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 173c63f21..6d7034ad4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -17,7 +17,7 @@ export class PriorityConfigsService { ) {} async findAll(filter: { - type?: 'WAGON' | 'CURRENCY'; + type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; isActive?: boolean; page?: number; pageSize?: number; @@ -87,12 +87,15 @@ export class PriorityConfigsService { await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction); } - private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void { + private validateCurrencyField( + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + currency: string | undefined | null, + ): void { if (type === 'CURRENCY' && !currency) { throw new BadRequestException('currency field is required when type is CURRENCY'); } - if (type === 'WAGON' && currency) { - throw new BadRequestException('currency field must be null when type is WAGON'); + if (type !== 'CURRENCY' && currency) { + throw new BadRequestException(`currency field must be null when type is ${type}`); } } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 2ad8753c3..6608749d1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -76,7 +76,6 @@ export class ServiceTypesService { includesFirstMile: dto.includesFirstMile ?? false, includesLastMile: dto.includesLastMile ?? false, includesCustoms: dto.includesCustoms ?? false, - priorityBonusPoints: dto.priorityBonusPoints ?? 0, isActive: dto.isActive ?? true, displayOrder, }); 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..31d3c8855 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,8 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; findBatchPoolByRouteDay: jest.Mock; + findBatchPoolByCorridorDay: jest.Mock; + findUnacceptedForRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -53,6 +55,8 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), + findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]), + findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -121,7 +125,10 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { + syncPayableDueDate: jest.fn().mockResolvedValue(undefined), + expirePayable: jest.fn().mockResolvedValue(undefined), + } as never, { emitPhase: jest.fn() } as never, ); }); @@ -196,6 +203,8 @@ describe('BookingBatchService — PAID reconcile', () => { cargoTotalWeightVgm: 10, freightType: 'CONTAINER', bookingContainers: [], + originYardId, + destinationYardId, }) as unknown as Booking; beforeEach(() => { @@ -227,13 +236,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 +252,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 +268,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 +296,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 +325,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); @@ -323,4 +337,177 @@ describe('BookingBatchService — PAID reconcile', () => { expect(notifier.payNow).not.toHaveBeenCalled(); }); }); + + describe('expireUnacceptedForRouteDay — doc-review sweep', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + + const pendingBooking = { + id: 'pending-1', + reference: 'BK-PENDING-1', + status: 'OPERATION_REQUEST_PENDING', + isGovernment: false, + originYardId, + destinationYardId, + } as unknown as Booking; + + beforeEach(() => { + // One fillable schedule on this corridor/day so corridorYardsForRouteDay + // resolves a non-empty yard set (legacy two-stop route → [origin, dest]). + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-1', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + }, + ]); + }); + + it('expires each un-accepted booking and clears its scheduled day', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith( + expect.arrayContaining([originYardId, destinationYardId]), + day, + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'pending-1', + expect.objectContaining({ + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + scheduledDate: null, + }), + ); + expect(notifier.expired).toHaveBeenCalledWith(pendingBooking); + }); + + it('is a no-op when nothing is un-accepted', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.expired).not.toHaveBeenCalled(); + }); + + it('does nothing when the route-day has no fillable schedule', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled(); + }); + }); + + describe('maybeOfferPartial — split-eligibility gate', () => { + const importGeneral = { + id: 'b1', + reference: 'b1', + isGovernment: false, + tradeDirection: 'IMPORT', + contractKind: 'GENERAL', + consolidationPartnerId: null, + } as unknown as Booking; + + const call = (booking: Booking, isPair: boolean): boolean => + ( + service as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(booking, isPair); + + it('allows IMPORT + GENERAL when splitService is present', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(importGeneral, false); + expect(eligible).toBe(true); + }); + + it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible( + { ...importGeneral, contractKind: 'ONE_TIME' } as Booking, + false, + ); + expect(eligible).toBe(true); + }); + + it('rejects when splitService is absent (default test service)', () => { + // `service` from the outer beforeEach was built without a splitService. + expect(call(importGeneral, false)).toBe(false); + }); + + it('rejects EXPORT, government, consolidated pairs, and other directions', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const check = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible.bind(withSplit); + + expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false); + expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false); + expect(check(importGeneral, true)).toBe(false); // consolidated pair + expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); + }); + }); }); 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..2a9169ab7 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 { @@ -78,6 +80,10 @@ export interface BatchBoardBooking { lengthMeters: number; paymentDeadline: string | null; state: BatchBoardBookingState; + /** Rule-engine priority score used to rank the batch (higher = boards first). */ + priorityScore: number; + /** CONTAINER | BULK — for the priority-tracking visuals. */ + freightType: string | null; } export type BookingAllocationStatus = @@ -459,18 +465,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 +495,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 +503,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'); } @@ -631,6 +642,8 @@ export class BookingBatchService implements OnModuleInit { ? b.paymentDeadline.toISOString() : null, state: this.boardState(b, linkedIds.has(b.id)), + priorityScore: Number(b.priorityScore ?? 0), + freightType: b.freightType ?? null, }; }); @@ -719,6 +732,8 @@ export class BookingBatchService implements OnModuleInit { ? b.paymentDeadline.toISOString() : null, state: this.boardState(b, linkedIds.has(b.id)), + priorityScore: Number(b.priorityScore ?? 0), + freightType: b.freightType ?? null, fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, @@ -1009,8 +1024,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; } @@ -1019,6 +1034,15 @@ export class BookingBatchService implements OnModuleInit { const units = this.groupConsolidatedPool(pool); let armed = false; + // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when + // reservations trickle instead of landing in one pass (a reserve() throwing + // mid-loop, e.g. schema drift, or a mis-synced capacity cap). + this.logger.debug( + `[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` + + `maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` + + `poolSize=${pool.length} units=${units.length}`, + ); + for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; @@ -1026,17 +1050,39 @@ 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)) { + // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + this.logger.debug( + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + ); + + 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 { + // Doesn't fit whole. A split-eligible import booking is offered the part + // that fits in the remaining room (top-up path splits the boundary + // booking, mirroring fillRouteDay); otherwise skip and try the next. + const cand: { id: string; budget: CorridorBudget; armed: boolean } = { + id: scheduleId, + budget, + armed, + }; + if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + armed = cand.armed; + continue; + } continue; // skip a unit that exceeds weight/length/wagons, try the next } } @@ -1049,11 +1095,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 +1152,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,24 +1166,31 @@ 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 // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); + // Batch fill trace: each train's caps + the day pool size at entry. + this.logger.debug( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` + + `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + + `poolSize=${pool.length} units=${units.length}`, + ); + for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; @@ -1146,20 +1199,42 @@ 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); + }); + + // Per-unit trace: chosen train + each train's remaining room on this leg. + this.logger.debug( + `[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `targetTrain=${target?.id ?? "none"} ` + + `rooms=${trains + .map((t) => { + const leg = legOn(t); + return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`; + }) + .join(",")}`, + ); 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; } @@ -1167,33 +1242,14 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // 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]; - if ( - partialTarget && - !booking.isGovernment && - booking.tradeDirection === "IMPORT" && - booking.contractKind === "GENERAL" && - this.splitService - ) { - const offered = await this.tryPartialOffer( - booking, - partialTarget.id, - partialTarget.budget, - need, - ); - if (offered) { - partialTarget.budget = this.subtract(partialTarget.budget, offered); - partialTarget.armed = true; - continue; - } - } - } + // Fits no train whole. A split-eligible booking is offered the largest + // part that fits on the train with the most free wagons on its leg (this + // covers both "fits nowhere" and the boundary case where earlier bookings + // already consumed most of the room). Consolidated pairs / government / + // non-import never split — isSplitEligible guards that. Passing the live + // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. + const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + if (offered) continue; // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1208,11 +1264,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); } @@ -1220,6 +1276,57 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } + /** + * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be + * offered a partial (split-on-payment). Consolidated pairs never split (both-or- + * neither shared wagon) and government bookings never split (they preempt). + */ + private isSplitEligible(booking: Booking, isPair: boolean): boolean { + return ( + !isPair && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && + this.splitService != null + ); + } + + /** + * Offer the largest fitting part of a booking that does not fit any candidate + * train whole, on the train with the most free wagons on the booking's leg. + * Mutates the chosen candidate's budget + armed flag in place. Returns true when + * an offer was opened (caller should `continue` past this unit), false otherwise. + * Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg + * is computed per candidate from the booking's yards, so callers pass their live + * train entries and only leg-carrying trains are considered. + */ + private async maybeOfferPartial( + booking: Booking, + isPair: boolean, + candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + need: Capacity, + ): Promise { + if (!this.isSplitEligible(booking, isPair)) return false; + const target = candidates + .map((c) => { + const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); + return leg ? { c, leg, room: c.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 (!target) return false; + const offered = await this.tryPartialOffer( + booking, + target.c.id, + target.room, + need, + ); + if (!offered) return false; + target.c.budget.subtract(offered, target.leg); + target.c.armed = true; + return true; + } + /** * Offer the largest fitting part of an over-capacity booking as a partial * (split-on-payment). Returns the capacity the offer consumes, or null when no @@ -1414,10 +1521,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 +1568,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 +1585,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) }; } @@ -1632,16 +1740,93 @@ export class BookingBatchService implements OnModuleInit { this.notifier.expired(booking); } + /** + * Union of stop yards across the day's fillable schedules on this corridor — + * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings + * are covered. Empty when no fillable schedule exists for the group. + */ + private async corridorYardsForRouteDay( + group: RouteDayGroup, + ): Promise { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const yards = new Set(); + for (const schedule of corridor) { + if ( + schedule.scheduledDepartureDate == null || + eatDay(schedule.scheduledDepartureDate) !== group.day + ) { + continue; + } + for (const yardId of await this.stopsForSchedule(schedule)) { + yards.add(yardId); + } + } + return [...yards]; + } + + /** + * Sweep bookings on a route-day whose operation request staff did NOT accept by + * the time the window's document-review phase ends. They never reached + * FULLY_EXECUTED, so they never enter the batch — expire them (customer must + * rebook a new window). No reservation and no invoice exists yet at this stage, + * so this is a lighter expiry than `expire()`: just flip status + notify, and + * best-effort close any payable if one was issued early. Government/export are + * excluded by the query. + */ + async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise { + const corridorYards = await this.corridorYardsForRouteDay(group); + if (corridorYards.length === 0) return; + const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( + corridorYards, + group.day, + ); + for (const booking of unaccepted) { + await this.bookingsRepository.update(booking.id, { + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", + // Free the shipment day so the customer can rebook a fresh window. + scheduledDate: null, + } as never); + // Close any payable issued before doc-review end (normally none — the invoice + // is created at ops-accept, which by definition has not happened here). + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID") + .catch(() => undefined); + this.notifier.expired(booking); + this.logger.log( + `Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` + + `(${group.originYardId}->${group.destinationYardId} ${group.day})`, + ); + } + } + /** * 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 +1840,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 +1872,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 +1982,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 +2059,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/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts new file mode 100644 index 000000000..6d706c423 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -0,0 +1,101 @@ +import { BookingSplitService } from './booking-split.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; + +/** + * applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL + * (both the parent contract row and the booking's denormalized copy) so the split + * remainder can be rebooked. A GENERAL booking is left untouched. + */ +describe('BookingSplitService — applySplit ONE_TIME promotion', () => { + const bookingId = 'bk-1'; + const contractId = 'ct-1'; + const offerId = 'of-1'; + + const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => { + const offer = { + id: offerId, + bookingId, + status: 'OFFERED', + offeredWagons: 3, + totalWagons: 5, + offeredWeightTons: 30, + offeredAmount: 300, + offeredPricingBreakdown: {}, + offeredLines: null, + } as unknown as BookingBatchOffer; + + const bookingRepo = { + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue({ + id: bookingId, + contractId, + contractKind: bookingContractKind, + }), + find: jest.fn().mockResolvedValue([]), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + const contractRepo = { update: jest.fn().mockResolvedValue(undefined) }; + const offerRepo = { + findOne: jest.fn().mockResolvedValue(offer), + update: jest.fn().mockResolvedValue(undefined), + }; + const containerRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn(), + softDelete: jest.fn(), + }; + const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() }; + + const repoFor = (entity: unknown) => { + if (entity === Booking) return bookingRepo; + if (entity === Contract) return contractRepo; + if (entity === BookingBatchOffer) return offerRepo; + if (entity === BookingContainer) return containerRepo; + if (entity === BookingContainerUnit) return unitRepo; + return { find: jest.fn().mockResolvedValue([]), update: jest.fn() }; + }; + + const dataSource = { + getRepository: jest.fn(repoFor), + transaction: jest.fn(async (fn: (m: unknown) => Promise) => { + await fn({ getRepository: repoFor }); + }), + }; + + const service = new BookingSplitService( + dataSource as never, + {} as never, + {} as never, + { expirePayable: jest.fn() } as never, + { payNowPartial: jest.fn() } as never, + ); + return { service, bookingRepo, contractRepo }; + }; + + it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => { + const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); + + await service.applySplit(bookingId); + + expect(bookingRepo.update).toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + expect(contractRepo.update).toHaveBeenCalledWith( + contractId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + }); + + it('leaves a GENERAL booking untouched (no contract promotion)', async () => { + const { service, contractRepo } = buildService('GENERAL'); + + await service.applySplit(bookingId); + + expect(contractRepo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 2cd2df6d9..44886f116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -9,6 +9,7 @@ import { BillingService } from '../billing/billing.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchOffer, OfferedLine, @@ -30,10 +31,11 @@ export interface SizedOffer { * pays, which is the act of accepting the split (applySplit). No payment → * offer expires and the booking stays whole. * - * Only GENERAL-contract commercial bookings are offered partials: the remainder + * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder * returns to the contract's quantity cap (derived live from booking_container * rows, so reducing the lines releases it automatically) and can be rebooked in - * any later window within contract validity. + * any later window within contract validity. A ONE_TIME contract is promoted to + * GENERAL on split (see applySplit) so its remainder is actually rebookable. */ @Injectable() export class BookingSplitService { @@ -245,6 +247,25 @@ export class BookingSplitService { pricingBreakdown: offer.offeredPricingBreakdown, } as never); + // A ONE_TIME contract permits a single active booking, which would block the + // split remainder from ever being rebooked. Promote the parent contract (and + // the booking's denormalized copy) to GENERAL so the leftover quantity draws + // down against the cap like any general contract, within the same validity. + const booking = await manager.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, contractId: true, contractKind: true }, + }); + if (booking?.contractKind === 'ONE_TIME') { + await manager + .getRepository(Booking) + .update(bookingId, { contractKind: 'GENERAL' } as never); + if (booking.contractId) { + await manager + .getRepository(Contract) + .update(booking.contractId, { contractKind: 'GENERAL' } as never); + } + } + await manager .getRepository(BookingBatchOffer) .update(offer.id, { status: 'APPLIED' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts new file mode 100644 index 000000000..f96c388f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -0,0 +1,201 @@ +import { BookingWindowService } from './booking-window.service'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Window state-machine tests: exercise the real advanceImport transitions and the + * concludeCycle reopen/done decision with mocked collaborators. Drives the exact + * production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and + * asserts the side effects the batch/settle/reopen flow depends on. + */ +describe('BookingWindowService — window state machine', () => { + const scheduleId = 'sched-1'; + + let service: BookingWindowService; + let batch: { + setWindow: jest.Mock; + processRouteDay: jest.Mock; + expireUnacceptedForRouteDay: jest.Mock; + settleDueReservations: jest.Mock; + isScheduleFull: jest.Mock; + }; + let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; + let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; + let updateMock: jest.Mock; + + const cfg = { + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 0, // 24h desk → reopen opens immediately + windowCloseHour: 0, + windowDurationHours: 1, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 0, + }; + + const baseSchedule = (over: Partial): TrainSchedule => + ({ + id: scheduleId, + direction: 'IMPORT', + originStationId: 'yard-o', + destinationStationId: 'yard-d', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + bookingCycleNo: 0, + windowOpensAt: null, + windowClosesAt: null, + docReviewEndsAt: null, + docReviewCompletedAt: null, + paymentPhaseEndsAt: null, + ...over, + }) as unknown as TrainSchedule; + + const advanceImport = (s: TrainSchedule, now: Date): Promise => + (service as unknown as { + advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise; + }).advanceImport(s, cfg, now); + const concludeCycle = (s: TrainSchedule, now: Date): Promise => + (service as unknown as { + concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise; + }).concludeCycle(s, cfg, now); + + beforeEach(() => { + updateMock = jest.fn().mockResolvedValue(undefined); + batch = { + setWindow: jest.fn().mockResolvedValue(undefined), + processRouteDay: jest.fn().mockResolvedValue(undefined), + expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), + settleDueReservations: jest.fn().mockResolvedValue(undefined), + isScheduleFull: jest.fn().mockResolvedValue(false), + }; + trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]), + }; + trainSchedulingService = { + finalizeSchedule: jest.fn().mockResolvedValue(undefined), + getWindowConfig: jest.fn().mockResolvedValue(cfg), + }; + + service = new BookingWindowService( + { getRepository: () => ({ update: updateMock }) } as never, + trainSchedulesRepository as never, + batch as never, + trainSchedulingService as never, + { directSend: jest.fn() } as never, + { notify: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + ); + }); + + it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => { + const s = baseSchedule({ + windowPhase: 'PRE_WINDOW', + windowOpensAt: new Date('2026-07-01T00:00:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('OPEN'); + expect(s.bookingCycleNo).toBe(1); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN'); + }); + + it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => { + const closesAt = new Date('2026-07-01T01:00:00.000Z'); + const s = baseSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowClosesAt: closesAt, + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('DOC_REVIEW'); + expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000)); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED'); + }); + + it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => { + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('PAYMENT'); + expect(s.paymentPhaseEndsAt).not.toBeNull(); + // Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity). + expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1); + expect(batch.processRouteDay).toHaveBeenCalledTimes(1); + const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0]; + const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0]; + expect(expireOrder).toBeLessThan(batchOrder); + }); + + it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => { + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future + docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('PAYMENT'); + }); + + it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => { + const s = baseSchedule({ + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); + expect(advanced).toBe(true); + // settleDueReservations runs (allocate paid / expire unpaid, then top-up). + expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); + }); + + it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { + batch.isScheduleFull.mockResolvedValue(true); + const s = baseSchedule({ windowPhase: 'PAYMENT' }); + await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z')); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL'); + expect(s.windowPhase).toBe('DONE'); + expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId); + }); + + it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => { + batch.isScheduleFull.mockResolvedValue(false); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + // departure well in the future so nextCycleOpensAt returns a real time. + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + }); + await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z')); + expect(s.windowPhase).toBe('PRE_WINDOW'); + expect(s.windowOpensAt).not.toBeNull(); + expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); + }); + + it('conclude: NOT full but NO cycle fits before departure → DONE', async () => { + batch.isScheduleFull.mockResolvedValue(false); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + // departure already passed → nextCycleOpensAt returns null → finish. + scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'), + }); + await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z')); + expect(s.windowPhase).toBe('DONE'); + }); + + it('no transition fires before its deadline (idempotent tick)', async () => { + const s = baseSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z')); + expect(advanced).toBe(false); + expect(s.windowPhase).toBe('OPEN'); + expect(batch.setWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 429a03a05..0f600d549 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -263,14 +263,19 @@ export class BookingWindowService implements OnModuleInit { ) { const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); - // Run the batch: priority fill over the route-day pool, reserving pay windows - // (or allocating government) — skipped automatically for everyone who fits - // is handled inside the fill (all fit → all reserved → all notified). - await this.bookingBatchService.processRouteDay({ + const routeDay = { originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day: eatDay(schedule.scheduledDepartureDate), - }); + }; + // Doc review is over: bookings staff never accepted (still pending) can no + // longer make this train — expire them BEFORE the batch so they never + // compete for capacity and never reach the pool. + await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay(routeDay); this.logger.log( `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, ); 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 67eac7bd1..326f8e204 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); @@ -3752,14 +3912,16 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`, [companyId], ); + // Nearest dispatch (departure) date first — the DISTINCT ON above forces a + // per-row ordering, so re-sort the mapped rows by departure for the client. return rows .map((r) => this.mapBookingWindowRow(r)) .sort((a, b) => { - const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; - const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; + const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; return ta - tb; }); } @@ -3801,7 +3963,7 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [contractId], ); return rows.map((r) => this.mapBookingWindowRow(r)); @@ -3839,7 +4001,7 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, ); return rows.map((r) => ({ ...this.mapBookingWindowRow({ @@ -4012,26 +4174,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 +4243,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 bf7139d72..d2562fe93 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 @@ -510,7 +510,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/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a57ce84c7..9aa9e169c 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -196,7 +196,6 @@ async function ensureReferences(manager: any) { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }), diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index ff4a34493..3ebcca6ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -138,7 +138,6 @@ async function main() { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }), diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index b67f1f572..989e18bf1 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 10, }, diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index be5e1c87d..c42d831bc 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -295,7 +295,6 @@ export class DemoBookingsSeeder { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }, 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-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 732653a9d..e1c46d168 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 11, }, 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/bookings/detail/BookingDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx new file mode 100644 index 000000000..3c36f757e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx @@ -0,0 +1,146 @@ +import { useMemo } from "react"; +import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { FileText, FolderOpen } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; +import { downloadBookingFile } from "@/services/files.service"; +import { useFileViewer } from "@/hooks/useFileViewer"; +import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; +import { SectionCard } from "./SectionCard"; + +interface LabeledFile { + label: string; + file: { id: string; name: string }; +} + +/** + * Every document tied to a booking, in one tab: the customer/GL clearance + * documents, the customs workflow files (declaration/duty/transit/Djibouti), + * the duty-tax notice, and the final invoice + payment slip. All fetched from + * the booking's clearance view (the only endpoint that surfaces booking files), + * each with inline view + download. + */ +export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) { + const { view, viewer } = useFileViewer(); + + const { data: clearance, isLoading, isError } = useQuery({ + queryKey: ["clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const onDownload = (f: { id: string; name: string }) => + void downloadBookingFile(f.id, f.name); + + // Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots). + const clearanceDocs = useMemo< + Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }> + >( + () => + (clearance?.documents ?? []) + .filter((d) => d.file) + .map((d) => ({ doc: d, file: d.file! })), + [clearance], + ); + + const workflowFiles = useMemo( + () => (clearance?.workflowFiles ?? []).filter((f) => f.file), + [clearance], + ); + + // Duty notice + final invoice + payment slip — loose files that don't ride in + // the documents/workflow arrays. + const otherFiles = useMemo(() => { + const rows: LabeledFile[] = []; + const notice = clearance?.dutyAdvice?.noticeFile; + if (notice) rows.push({ label: "Duty & tax notice", file: notice }); + const inv = clearance?.finalInvoice; + if (inv?.invoiceFile) + rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile }); + if (inv?.slipFile) + rows.push({ label: "Final invoice payment slip", file: inv.slipFile }); + return rows; + }, [clearance]); + + if (isLoading) { + return ( +
+ + + Loading documents… + +
+ ); + } + + const hasAny = + clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0; + + if (isError || !hasAny) { + return ( + +
+ + + No documents yet + + {isError + ? "Couldn’t load this booking’s documents." + : "Documents attached to this booking will appear here as they’re uploaded."} + + +
+
+ ); + } + + return ( + + {clearanceDocs.length > 0 && ( + + + {clearanceDocs.map(({ doc, file }) => ( + + ))} + + + )} + + {workflowFiles.length > 0 && ( + + )} + + {otherFiles.length > 0 && ( + + + {otherFiles.map((row) => ( + + ))} + + + )} + + {viewer} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 003f3d4de..b948cc5dc 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -1,6 +1,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; +export * from "./BookingDocumentsPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; 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/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index db6e79430..dc59c4dfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -646,7 +646,9 @@ function FinalInvoiceStep({ const invoice = clearance.finalInvoice ?? null; const paid = invoice?.status === "PAID"; - if (!clearance.offloaded && !invoice) { + // Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the + // secured gate pass is enough to open invoicing. Sending an invoice is optional. + if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) { return ( - Cargo offloaded — send the final invoice to the customer. + Send the final invoice to the customer if post-arrival charges apply (optional). - - - - - - - - deleteMutation.mutate({ id: setting.id })} - > - - - - ); }, }, ]; - }, [deleteMutation]); + }, []); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; @@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() { - - - } /> 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 12dd90b4c..93c02682a 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 1bc12b06b..a9ad6ea39 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" }, @@ -181,6 +187,7 @@ const CURRENCIES = [ const PRIORITY_CONFIG_TYPES = [ { label: "Wagon count", value: "WAGON" }, { label: "Payment currency", value: "CURRENCY" }, + { label: "Customs clearance", value: "CUSTOMS" }, ]; const codeColumn = (key: string, header = "Code"): ResourceColumn => ({ @@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ slug: "priority-configs", label: "Priority Rules", category: "rules", - subtitle: "Wagon-count and payment-currency scoring rules", + subtitle: "Wagon-count, payment-currency, and customs scoring rules", searchPlaceholder: "Search priority rules...", orderConfig: { field: "displayOrder", label: "Display order" }, columns: [ @@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ optional: true, options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES], placeholder: "Select a currency", - hideWhen: { field: "type", equals: ["WAGON"] }, + hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] }, }, { name: "minWagonCount", label: "Min wagon count", type: "number", required: true }, { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true }, @@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ codeColumn("code"), { id: "serviceName", header: "Service name", accessorKey: "serviceName" }, { id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" }, - { id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" }, activeColumn, ], formFields: [ @@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "includesFirstMile", label: "Includes first mile", type: "boolean" }, { name: "includesLastMile", label: "Includes last mile", type: "boolean" }, { name: "includesCustoms", label: "Includes customs", type: "boolean" }, - { name: "priorityBonusPoints", label: "Priority bonus points", type: "number" }, { name: "isActive", label: "Active", type: "boolean" }, ], }, @@ -431,7 +436,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/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index de6ecfa9b..6daf79221 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -33,6 +33,7 @@ import { RefreshCw, Ruler, TrainFront, + Trophy, Weight, XCircle, } from "lucide-react"; @@ -55,6 +56,8 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; +import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { BookingsManager } from "./BookingsManager"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() { api.trainScheduling.batchBoardDetail.queryOptions({ input: { scheduleId: scheduleId ?? "" }, enabled: Boolean(scheduleId), - refetchInterval: 30_000, + // Poll fast while a window cycle is actively moving (open / doc-review / + // payment) so the priority ranking + pay countdowns stay live; back off to + // 30s once the cycle is idle (pre-window / closed / done). + refetchInterval: (query) => { + const phase = (query.state.data as BatchBoardScheduleDetail | undefined) + ?.windowPhase; + return phase === "OPEN" || + phase === "DOC_REVIEW" || + phase === "PAYMENT" + ? 5_000 + : 30_000; + }, }), ); + // Keep the board in sync with server-pushed window-phase transitions too + // (invalidates the batch-board list + patches window carousels). + useBookingWindowSocket(Boolean(scheduleId)); const runAllocation = useMutation( api.trainScheduling.runAllocation.mutationOptions(), ); @@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() { Overview + } + > + Priority Tracking{" "} + {allBookings.length > 0 && `(${allBookings.length})`} + Train Composition{" "} {scheduleDetailQuery.data?.trainSet?.wagons && @@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() { + + + + {scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? ( 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..dc9a29db8 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", @@ -206,7 +207,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; + serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; @@ -238,7 +239,6 @@ export interface BookingListRow { priorityScore: number; schedulingStatus?: string; serviceTypeLabel?: string; - serviceTypeBonus?: number; trainScheduleId?: string | null; isGovernment?: boolean; governmentInstitution?: string | null; 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 68ad375d8..29190846d 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -225,6 +225,10 @@ export interface BatchBoardBooking { lengthMeters: number; paymentDeadline: string | null; state: BatchBoardBookingState; + /** Rule-engine priority score used to rank the batch (higher = boards first). */ + priorityScore: number; + /** CONTAINER | BULK — for the priority-tracking visuals. */ + freightType: string | null; } /** @@ -359,6 +363,8 @@ export interface BookingWindow { isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -794,3 +800,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/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 5b91af82e..6df836357 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -8,6 +8,7 @@ import { useEffect } from "react"; import { io } from "socket.io-client"; import { API_BASE_URL } from "@/constants/apiConfig"; +import type { MyBookingWindow } from "@/services/bookings.service"; function getAuthToken(): string | undefined { return document.cookie @@ -20,11 +21,52 @@ function getAuthToken(): string | undefined { // prefix — strip a trailing `/api` if the base URL carries one. const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); +// Both window lists live under this key prefix (myBookingWindows + +// contractBookingWindows/*), so one predicate patches every cached list. +const WINDOW_KEY_PREFIX = ["train-scheduling"] as const; +const WINDOW_ACTIONS = new Set(["myBookingWindows", "contractBookingWindows"]); + +/** + * Fold a server phase push onto a cached window row. `isOpenNow` is recomputed + * exactly as the server's mapBookingWindowRow does (phase OPEN + status OPEN) so + * the live-patched state can never disagree with what a fresh REST fetch returns + * on refresh — both come from the same server timestamps, not the client clock. + */ +function applyEvent( + row: MyBookingWindow, + event: BookingWindowPhaseEvent, +): MyBookingWindow { + return { + ...row, + windowPhase: event.phase, + bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus, + bookingCycleNo: event.bookingCycleNo, + isOpenNow: + event.phase === "OPEN" && event.bookingWindowStatus === "OPEN", + windowOpensAt: event.windowOpensAt, + windowClosesAt: event.windowClosesAt, + docReviewEndsAt: event.docReviewEndsAt, + paymentPhaseEndsAt: event.paymentPhaseEndsAt, + departureDate: event.scheduledDepartureDate ?? row.departureDate, + }; +} + /** * Subscribes to live booking-window pushes. Every phase transition the window - * engine applies (open, doc review, payment, reopen, done) invalidates the - * cached window lists, so the home-page "Booking Windows" card flips the - * moment the backend does — the 60s poll remains only as a fallback. + * engine applies (open, doc review, payment, reopen, done) carries the schedule's + * full new state; we fold it straight into the cached window lists with + * setQueriesData rather than invalidating. + * + * Why not invalidate: at ~200 concurrent users a namespace-wide broadcast made + * every client refetch two heavy window queries on every schedule transition — + * an O(users × schedules) stampede that lagged the whole population. Patching the + * cache in place means a push costs each client one array map, no network. It + * also fixes the refresh-jump: the live state and a post-refresh REST fetch now + * derive isOpenNow/phase from the same server fields, so they agree. + * + * A push for a schedule not present in any cached list (a brand-new window) can't + * be patched in — those fall back to a debounced invalidate so the new row still + * appears, without the storm. */ export function useBookingWindowSocket(enabled: boolean) { const qc = useQueryClient(); @@ -52,19 +94,59 @@ export function useBookingWindowSocket(enabled: boolean) { console.debug("[booking-windows] socket disconnected:", reason), ); + // Coalesce the "unknown schedule → refetch" fallback so a burst of pushes + // for new schedules triggers at most one invalidation per window. + let refetchTimer: ReturnType | null = null; + const scheduleRefetch = () => { + if (refetchTimer) return; + refetchTimer = setTimeout(() => { + refetchTimer = null; + void qc.invalidateQueries({ + predicate: (q) => { + const [prefix, action] = q.queryKey as unknown[]; + return prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action)); + }, + }); + }, 800); + }; + socket.on( BOOKING_WINDOW_WS_EVENTS.PHASE, - (_event: BookingWindowPhaseEvent) => { - qc.invalidateQueries({ - queryKey: ["train-scheduling", "myBookingWindows"], - }); - qc.invalidateQueries({ - queryKey: ["train-scheduling", "contractBookingWindows"], - }); + (event: BookingWindowPhaseEvent) => { + let patchedSomewhere = false; + + qc.setQueriesData( + { + predicate: (q) => { + const [prefix, action] = q.queryKey as unknown[]; + return ( + prefix === WINDOW_KEY_PREFIX[0] && + WINDOW_ACTIONS.has(String(action)) + ); + }, + }, + (rows) => { + if (!rows) return rows; + let changed = false; + const next = rows.map((row) => { + if (row.scheduleId !== event.scheduleId) return row; + changed = true; + patchedSomewhere = true; + return applyEvent(row, event); + }); + return changed ? next : rows; + }, + ); + + // The schedule wasn't in any cached list — a newly announced window (or a + // lane the client hasn't fetched). Fall back to a debounced refetch so it + // surfaces, without the per-push stampede that patching avoids. + if (!patchedSomewhere) scheduleRefetch(); }, ); return () => { + if (refetchTimer) clearTimeout(refetchTimer); socket.off(); socket.disconnect(); }; 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 ( [...windows].sort((a, b) => { - const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); - if (openDiff !== 0) return openDiff; - const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; - const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; - return at - bt; + const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; + const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; + if (da !== db) return da - db; + return Number(b.isOpenNow) - Number(a.isOpenNow); }), [windows], ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 44ec5809e..239c42bac 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -26,6 +26,7 @@ export const ACTIVE_STATUSES = [ "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT", + "ARRIVED", ]; export interface StageConfig { @@ -346,6 +347,19 @@ export const STATUS_CONFIG: Record = { 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 new file mode 100644 index 000000000..51125194c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -0,0 +1,412 @@ +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Group, + Paper, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; +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"; +const BORDER = "#E6ECF2"; + +/** All window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; + +function fmtDay(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); +} + +/** "Thu, 10 Jul · 08:00 – 11:00 EAT" (or a phase label when times are unset). */ +function windowLabel(w: MyBookingWindow): string { + if (w.windowOpensAt && w.windowClosesAt) { + return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( + w.windowClosesAt, + )} EAT`; + } + if (w.windowOpensAt) { + return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; + } + return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); +} + +/** + * The countdown for whichever phase the window is currently in, mirroring the + * home dashboard's Booking Windows card. `expiredText` names the NEXT step so a + * deadline that lapses between refetches announces what comes next rather than + * the bare "Expired". + */ +function phaseCountdown( + w: MyBookingWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + } + : null; + case "OPEN": + return w.windowClosesAt + ? { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { + label: "Payment due in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + } + : null; + default: + return null; + } +} + +/** + * Drop windows the SERVER considers finished. Keyed off the server's windowPhase + * — never the client clock. The server query already excludes terminal + * (DONE / CLOSED_FOR_DAY) and departed rows; comparing `Date.now()` against the + * row's timestamps here only re-introduced clock skew, which made a card vanish + * on one machine and reappear after refresh. So we trust the phase the server + * sends (live-patched over the socket) and let it drive visibility. + */ +function isPast(w: MyBookingWindow): boolean { + return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY"; +} + +function WindowCard({ w }: { w: MyBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + +/** + * 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[]; + isLoading: boolean; +} + +/** + * Booking windows on THIS contract's routes only (the API filters by the + * contract's route lanes, which also pins the import/export direction) — the + * contract-scoped counterpart of the home dashboard's all-lanes Booking Windows + * card. Paged three cards at a time; hidden when nothing is announced. + */ +export function ContractBookingWindowsSection({ + windows, + isLoading, +}: ContractBookingWindowsSectionProps) { + const [page, setPage] = useState(0); + + const sorted = useMemo(() => { + const rows = windows.filter( + (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), + ); + // Order by the train's dispatch (departure) date, nearest first — the + // shipment leaving soonest leads. Open-now breaks ties on the same departure. + return rows.sort((a, b) => { + const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; + const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; + if (da !== db) return da - db; + return Number(b.isOpenNow) - Number(a.isOpenNow); + }); + }, [windows]); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + + return ( + + + + + + + Booking windows + + + Windows on this contract's routes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : 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) => ( + + ))} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 560d7b11e..8f045861c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -64,6 +64,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; +import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection"; import { BORDER, ContractStatusBadge, @@ -200,7 +201,7 @@ export default function ContractDetailPage() { // Booking windows for this contract's routes — gates the direct "New shipment // booking" entry so the customer only sees it while a window is open. // Refetched every minute so "Open now" flips without a manual reload. - const { data: bookingWindows = [] } = useQuery({ + const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({ ...api.bookings.getContractBookingWindows.queryOptions({ input: { contractId: id! }, refetchInterval: 60_000, @@ -515,6 +516,16 @@ export default function ContractDetailPage() { + {/* Booking windows on this contract's routes/direction only (the API + filters by the contract's lanes). Intercity contracts aren't + window-gated, so nothing is shown for them. */} + {contract.tradeDirection !== "DOMESTIC" && ( + + )} + {/* Tabs: Details · Documents · Bookings (pill style, like the backoffice booking-requests page; each tab shows a count badge). */} ({ 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, }, ]; @@ -662,7 +656,9 @@ export default function NewContractPage({ {isEdit - ? "Update your contract details and documents, then resubmit it for EDR staff review." + ? editContract?.status === "CHANGES_REQUESTED" + ? "Update your contract details and documents, then resubmit it for EDR staff review." + : "Update your draft contract details and documents, then submit it for EDR staff review." : "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."} @@ -685,7 +681,7 @@ export default function NewContractPage({ onSubmit={(e) => e.preventDefault()} > - {isEdit && ( + {isEdit && editContract?.status === "CHANGES_REQUESTED" && ( !w.isOpenNow && w.windowOpensAt) - .sort( - (a, b) => - new Date(a.windowOpensAt!).getTime() - - new Date(b.windowOpensAt!).getTime(), - ); + .sort((a, b) => { + const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; + const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; + if (da !== db) return da - db; + return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(); + }); return upcoming[0] ?? null; } 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 (