diff --git a/README.md b/README.md index 125561b9d..50e0b55f6 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed - 3 User accounts (Admin, Passenger, Agent) - Fare rules for ADULT and CHILD passenger categories - Currency exchange rates (ETB, DJF, USD) -- Baggage allowance rules +- Luggage allowance rules - Notification templates - Promotions and FAQ content - Menu items and station crowd signals 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/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts index 1bd3bbc27..55a6568c3 100644 --- a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -12,23 +12,28 @@ export class AddEmailToOtpVerifications1900000000000 name = "AddEmailToOtpVerifications1900000000000"; public async up(queryRunner: QueryRunner): Promise { + // The table lives in the `freight` schema (the OtpVerification entity pins + // schema: "freight"). An earlier version of this migration targeted + // `public.otp_verifications`, which does not exist there — leaving the real + // freight table without an `email` column and OTP send failing with + // `column OtpVerification.email does not exist`. Target `freight` explicitly. await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ALTER COLUMN phone DROP NOT NULL `); await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ADD COLUMN IF NOT EXISTS email varchar UNIQUE `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications DROP COLUMN IF EXISTS email `); await queryRunner.query(` - ALTER TABLE public.otp_verifications + ALTER TABLE freight.otp_verifications ALTER COLUMN phone SET NOT NULL `); } 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/migrations/2020000000000-RepairOtpEmailSchema.ts b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts new file mode 100644 index 000000000..e11923a4d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair: AddEmailToOtpVerifications1900000000000 originally altered + * `public.otp_verifications`, but the OtpVerification entity pins + * schema: "freight". On any DB where that migration already ran (and is recorded + * as executed, so it won't run again), the real `freight.otp_verifications` table + * never got the `email` column and `phone` was never made nullable — so OTP send + * dies with `column OtpVerification.email does not exist`. + * + * This migration re-applies the change against the correct schema. Idempotent + * (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the + * freight table is absent. + */ +export class RepairOtpEmailSchema2020000000000 implements MigrationInterface { + name = "RepairOtpEmailSchema2020000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + DROP COLUMN IF EXISTS email + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts new file mode 100644 index 000000000..333a0f541 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in + * kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo + * weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes + * and is NOT touched; truck gross weight has no data yet. Runs exactly once + * (tracked by TypeORM) — re-running would divide again. + */ +export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface { + name = 'WarehouseCapacityKgToTons2020000000000'; + + private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones']; + private readonly columns = ['capacity_weight', 'current_weight', 'max_weight']; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts new file mode 100644 index 000000000..a05465c53 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds train_schedules.reference: a human-facing unique schedule number + * S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN). + * + * - Adds the nullable column. + * - Backfills existing rows: within each created-at year, numbers rows by + * created_at ascending (oldest → S--00001). Deterministic order. + * - Adds a partial unique index (NULLs allowed so a future insert can stage + * the row before the app stamps its reference). + */ +export class AddTrainScheduleReference2030000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reference VARCHAR(20); + `); + + // Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's + // own created-at year as the reference year so historical rows keep a + // sensible number. + await queryRunner.query(` + WITH numbered AS ( + SELECT + id, + EXTRACT(YEAR FROM created_at)::int AS yr, + ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM created_at) + ORDER BY created_at ASC, id ASC + ) AS seq + FROM freight.train_schedules + WHERE reference IS NULL + ) + UPDATE freight.train_schedules ts + SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0') + FROM numbered + WHERE ts.id = numbered.id; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference + ON freight.train_schedules (reference) + WHERE reference IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.ux_train_schedules_reference; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference; + `); + } +} 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/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 6f0ec6f55..43c14c7f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -42,16 +42,16 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - const isExport = booking.tradeDirection === 'EXPORT'; const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are - // not pre-specified — they are registered + weighed when the truck leaves. - if (isExport) { - if (requested.length < 1 || requested.length > 2) { - throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); - } - } else if (requested.length > 2) { + // Both import and export specify the containers each truck carries. Capacity + // is size-based: a 40ft container fills the truck (max 1); two 20ft containers + // fit (max 2), no size mixing. #trucks <= #containers follows naturally since + // each container is assigned to exactly one truck. + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { throw new BadRequestException('A truck carries at most 2 containers'); } @@ -68,6 +68,13 @@ export class CustomerTruckService { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } + // Size cap: a 40ft container fills the truck. + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } } await this.dataSource.transaction(async (manager) => { @@ -244,7 +251,7 @@ export class CustomerTruckService { } } - const grossKg = await this.vgmKgForContainers(bookingId, requested); + const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( @@ -256,18 +263,19 @@ export class CustomerTruckService { }), ), ); - // Provisional gross from the loaded containers' VGM — overridden by the - // weighed gross on departure. + // Provisional gross (tonnes) from the loaded containers' VGM — overridden + // by the weighed gross on departure. (Column is *_kg but holds tonnes.) await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { - grossWeightKg: grossKg, + grossWeightKg: grossTons, }); }); return this.listTrucks(bookingId); } - private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { - const [row]: Array<{ kg: string }> = await this.dataSource.query( - `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + /** Summed VGM (tonnes) of the given containers — provisional truck gross. */ + private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ tons: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -276,7 +284,7 @@ export class CustomerTruckService { AND bcu.deleted_at IS NULL`, [bookingId, numbers], ); - return Number(row?.kg ?? 0); + return Number(row?.tons ?? 0); } /** @@ -399,4 +407,20 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await this.dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((r) => (r.size ?? '').trim()); + } } 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..55dd5c29f 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, @@ -214,6 +217,20 @@ export class ContractBookingService { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. Roll back the just-inserted row + its lines so it + // does NOT occupy the one-time contract's single active-booking slot — else + // the customer's retry hits "already has an active booking" against a broken + // draft. The customer must fix the contract's rates, then rebook. + if (!(computed.totalAmount > 0)) { + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.hardDelete(booking.id); + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, priorityScore: computed.priorityScore, @@ -232,6 +249,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 +355,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 +368,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 +391,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/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 67fbdec9b..e26ed35c9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -72,8 +72,14 @@ export class OtpService { message: "OTP sent successfully", }; } catch (error) { - console.log(error); - + // Log the real cause (DB/SMS/email failure) with its stack so a deployed + // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. + this.logger.error( + `Failed to send OTP to ${target.email ?? target.phone}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); throw new BadRequestException("Failed to send OTP"); } } 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-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index f82d9696d..899b302fc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; + // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule + // list, booking windows, and load lists. Assigned at creation from the highest + // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). + @Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true }) + reference?: string | null; + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 58e71143c..700a38983 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository { ): Promise { await this.repo(manager).update(id, { status, ...extra } as never); } + + /** + * Highest NNNNN sequence already issued for `S--…` references. Includes + * soft-deleted rows so the next number never reuses one still occupying the + * unique index (see the same pattern on BookingsRepository). + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository + .createQueryBuilder('schedule') + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(schedule.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('schedule.reference LIKE :prefix', { prefix: `S-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); + } } 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..2c901b1ff 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 { @@ -16,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -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'; @@ -239,6 +242,7 @@ const DEFAULT_TRAIN_LIMITS: Required = { /** Raw row shape for the booking-window queries (company- and contract-scoped). */ interface BookingWindowRow { schedule_id: string; + reference: string | null; contract_id: string | null; contract_kind: string | null; direction: string | null; @@ -276,6 +280,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 +295,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) { @@ -806,20 +822,24 @@ export class TrainSchedulingService { ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; - const schedule = manager.getRepository(TrainSchedule).create({ - trainSetId: trainSet.id, - routeId: route.id, - originStationId: route.originYardId, - destinationStationId: route.destinationYardId, - scheduledDepartureDate: departure, - status: TrainScheduleStatusEnum.Draft, - direction, - maxWagons: ( - await this.resolveTrainLimitConfig(dto, limitLoco) - ).maxWagonsPerTrain, - ...windowFields, - }); - const saved = await manager.getRepository(TrainSchedule).save(schedule); + const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) + .maxWagonsPerTrain; + // Retry past a concurrent insert that grabbed the same S- sequence + // (the unique index rejects the loser; it re-reads the max and tries again). + const saved = await this.insertScheduleWithReference(manager, (reference) => + manager.getRepository(TrainSchedule).create({ + reference, + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: departure, + status: TrainScheduleStatusEnum.Draft, + direction, + maxWagons, + ...windowFields, + }), + ); // Locomotives stay in their current status until dispatch — advance scheduling // must not block the locomotive from serving earlier trains. return saved.id; @@ -1457,6 +1477,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 +1526,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 +2470,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 +2492,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 +2540,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); @@ -2503,7 +2565,8 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: { booking: true }, }, - order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + // Newest-created first (the client can re-sort; this is the default order). + order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' }, }); return schedules.map((s) => this.mapScheduleListItem(s)); } @@ -2635,17 +2698,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 +2767,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 +2822,12 @@ export class TrainSchedulingService { containerWagonType, bulkWagonType, }); + this.stampSlotLegs( + wagonPlan, + fittingBookings, + dto.originStationId, + dto.destinationStationId, + ); violations.push( ...(await this.validatePhysicalFleetForPlan( @@ -3047,6 +3155,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 +3209,7 @@ export class TrainSchedulingService { sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, + boardYardId: slot.boardYardId ?? null, })), wagons, targetScheduleId, @@ -3108,7 +3218,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 +3251,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 +3431,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 +3482,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); @@ -3625,9 +3791,41 @@ export class TrainSchedulingService { return null; } + /** + * Insert a schedule with a freshly generated S--NNNNN reference, retrying + * past a concurrent insert that grabbed the same sequence (the unique index + * rejects the loser). Mirrors insertWithGeneratedReference for bookings, but + * runs inside the caller's transaction manager so the row joins the same commit. + */ + private async insertScheduleWithReference( + manager: EntityManager, + build: (reference: string) => TrainSchedule, + ): Promise { + const year = new Date().getFullYear(); + const repo = manager.getRepository(TrainSchedule); + for (let attempt = 0; attempt < 5; attempt += 1) { + const seq = await this.trainSchedulesRepository.maxReferenceSequence(year); + const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`; + try { + return await repo.save(build(reference)); + } catch (err) { + // 23505 = unique_violation on ux_train_schedules_reference; re-read + retry. + const code = (err as { driverError?: { code?: string } })?.driverError?.code; + if (err instanceof QueryFailedError && code === '23505' && attempt < 4) { + continue; + } + throw err; + } + } + // Unreachable — the loop either returns or throws — but satisfies the compiler. + throw new ConflictException('Could not allocate a unique schedule reference'); + } + private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { return { id: schedule.id, + reference: schedule.reference ?? null, + createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, @@ -3722,6 +3920,7 @@ export class TrainSchedulingService { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ON (ts.id) ts.id AS schedule_id, + ts.reference AS reference, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -3752,14 +3951,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; }); } @@ -3772,6 +3973,7 @@ export class TrainSchedulingService { async getBookingWindowsForContract(contractId: string) { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, + ts.reference AS reference, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -3801,7 +4003,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)); @@ -3819,6 +4021,7 @@ export class TrainSchedulingService { } > = await this.dataSource.query( `SELECT ts.id AS schedule_id, + ts.reference AS reference, ts.train_number, ts.direction, ts.window_phase, @@ -3839,7 +4042,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({ @@ -3854,6 +4057,7 @@ export class TrainSchedulingService { private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, + reference: r.reference ?? null, contractId: r.contract_id, contractKind: r.contract_kind, direction: r.direction, @@ -4012,26 +4216,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 +4285,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, @@ -4170,6 +4419,7 @@ export class TrainSchedulingService { return { id: schedule.id, + reference: schedule.reference ?? null, status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, 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/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index ccdda90d8..56b9d0810 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseYardDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts index fbb057fd5..eb29f751f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 5a8025948..900a9d8ac 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -47,7 +47,7 @@ export class CreateWarehouseDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index 063bb7d1d..c81550cd0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,7 +6,7 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; - @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() @Min(0) 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-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index ff25258d3..0a0c56821 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -44,7 +44,7 @@ export class WarehouseInspectionService { expectedWeight: expected, actualWeight: actual, weightLoss, - weightLossUnit: weightLoss !== null ? 'kg' : null, + weightLossUnit: weightLoss !== null ? 't' : null, hasMissingItems: dto.hasMissingItems ?? false, missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, 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..c31fd471e 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 { @@ -2020,7 +2020,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -2677,7 +2677,7 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.plateNumber], ['Driver', data.driverName], ['Truck Type', data.truckType], - ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`], ['Gate-Out Time', gateOut], ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], ]; @@ -3608,8 +3608,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Received Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Received Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Volume', data.volume == null ? null : data.volume.toLocaleString()], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3731,7 +3731,7 @@ export class WarehouseInventoryService { `${(data.truckPlateNumber && data.truckWeightKg ? data.truckWeightKg : data.weight - ).toLocaleString()} kg`, + ).toLocaleString()} t`, ], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3894,8 +3894,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Inventory Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Inventory Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], @@ -3968,8 +3968,8 @@ export class WarehouseInventoryService { 1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)} Container${esc(data.containerNumber)} Booking Containers${esc(data.bookingContainerSummary)} - Inventory Weight${esc(`${data.weight.toLocaleString()} kg`)} - Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)} + Inventory Weight${esc(`${data.weight.toLocaleString()} t`)} + Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}
Handover Clause
@@ -4303,9 +4303,9 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} kg`, - grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`, + `Tare Weight: ${tareWeight} t`, + grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, + computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -4357,7 +4357,7 @@ export class WarehouseInventoryService { } private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { - const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, ''); + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, ''); if (!value) return undefined; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; @@ -4420,9 +4420,9 @@ export class WarehouseInventoryService { truck?.driverName ? `Driver: ${truck.driverName}` : null, truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null, truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, - truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, @@ -4430,8 +4430,8 @@ export class WarehouseInventoryService { truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null, truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, 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/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index 7eb995ac2..e253a8db8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -16,7 +16,7 @@ import { Group, Loader, Paper, - Progress, + RingProgress, Stack, Text, ThemeIcon, @@ -26,7 +26,11 @@ import { import { PageContainer } from "@/components/page"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; -import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; +import { + RouteCorridor, + StatusPill, + scheduleBrand, +} from "@/components/trainScheduling/scheduleVisuals"; import { freightBrand } from "@/theme/freight-brand"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) { }); } -/** Compact icon + label + value cell used in the header meta strip. */ -function MetaStat({ +/** + * A single fact in the hero's glass meta strip — icon chip + uppercase label + + * value, laid on the translucent panel over the gradient. + */ +function HeroStat({ icon, label, value, @@ -63,15 +70,33 @@ function MetaStat({ value: string; }) { return ( - - + + {icon} - - - + + + {label} - + {value} @@ -79,6 +104,38 @@ function MetaStat({ ); } +/** Section header — icon chip + title + one-line hint. Shared by the cards. */ +function SectionHead({ + icon, + title, + hint, +}: { + icon: React.ReactNode; + title: string; + hint: string; +}) { + return ( + + + {icon} + + + + {title} + + + {hint} + + + + ); +} + +const CARD_STYLE = { + borderColor: scheduleBrand.mutedBorder, + boxShadow: scheduleBrand.shadowSm, +} as const; + export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); @@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() { const canLog = track.status === "DISPATCHED"; const totalStations = track.stations.length; const reached = Math.min(track.currentSequenceNo + 1, totalStations); - const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; + const progressPct = + totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0; const clampedPct = Math.min(100, Math.max(0, progressPct)); - const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; + const currentStation = + track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"; + const inTransit = track.status === "DISPATCHED"; + const arrived = track.status === "ARRIVED"; const handleLog = (sequenceNo: number) => { const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; @@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() { Back to schedule - {/* Header */} - - - - - - - - - - - Train tracking - - {track.trainNumber ? ( - - {track.trainNumber} - - ) : null} - {track.direction ? ( - - {track.direction} - - ) : null} - - - + {/* ── Hero: gradient wash, route + a bold progress ring woven together ── */} + + + {/* soft decorative glow, purely artistic */} + + + + {/* left — identity + route */} + + + + - - - - + + + + Train tracking + + {track.trainNumber ? ( + + {track.trainNumber} + + ) : null} + {track.direction ? ( + + {track.direction} + + ) : null} + + + + + + - {/* Journey progress */} - - - - Journey progress - - - {reached} / {totalStations} stations · {Math.round(clampedPct)}% - - - - + + + + + {arrived + ? "Journey complete" + : inTransit + ? `En route · ${currentStation}` + : "Awaiting dispatch"} + + + + - {/* Meta strip */} - - } label="Current" value={currentStation} /> - } - label="Departed" - value={formatDateTime(track.actualDepartureAt)} - /> - } - label="Arrived" - value={formatDateTime(track.actualArrivalAt)} - /> - } - label="Stations" - value={`${reached} of ${totalStations}`} + {/* right — progress ring, the artistic focal point */} + + + {Math.round(clampedPct)}% + + + {reached}/{totalStations} stops + + + } /> - + + + {/* glass meta strip below the wash */} + + } label="Current" value={currentStation} /> + } + label="Departed" + value={formatDateTime(track.actualDepartureAt)} + /> + } + label="Arrived" + value={formatDateTime(track.actualArrivalAt)} + /> + } + label="Stations" + value={`${reached} of ${totalStations}`} + /> + - {/* Corridor */} - + {/* ── Route corridor ── */} + - - - - - - - Route corridor - - - {canLog - ? "Log the train passing each station; the final station marks arrival." - : track.status === "ARRIVED" - ? "This train has arrived at its destination." - : "Tracking becomes available once the train is dispatched."} - - - - + } + title="Route corridor" + hint={ + canLog + ? "Log the train passing each station; the final station marks arrival." + : arrived + ? "This train has arrived at its destination." + : "Tracking becomes available once the train is dispatched." + } + /> - {/* Checkpoint log */} - - - - - - - - Checkpoint log - - - {track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded - - + {/* ── Checkpoint log ── */} + + + } + title="Checkpoint log" + hint={`${track.checkpoints.length} event${ + track.checkpoints.length === 1 ? "" : "s" + } recorded`} + /> {track.checkpoints.length === 0 ? ( - - - + + + - + No checkpoints yet - - Each station the train passes will be logged here with its timestamp. + + Each station the train passes will be logged here with its + timestamp. ) : ( - + {track.checkpoints.map((cp) => ( : } + bullet={ + cp.kind === "ARRIVED" ? ( + + ) : ( + + ) + } title={ @@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() { size="xs" radius="sm" variant="light" - color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"} + color={ + cp.kind === "ARRIVED" + ? "teal" + : cp.kind === "DEPARTED" + ? "blue" + : "edr-green" + } > {cp.kind} 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..f4cd3be02 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"; @@ -863,6 +864,16 @@ export default function TrainScheduleV2DetailPage() { + {schedule.reference ? ( + + {schedule.reference} + + ) : null} {schedule.route?.name ?? "Train schedule"} @@ -1131,6 +1142,7 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? : null} {scheduleId ? ( ( + "createdAt", + ); + const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [createOpen, setCreateOpen] = useState(false); const [windowSettingsId, setWindowSettingsId] = useState(null); const [editDateSchedule, setEditDateSchedule] = @@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() { return base; }, [allSchedules]); + // Distinct origins/destinations present in the loaded schedules, for the + // corridor filters. Sorted A→Z; "ALL" prepended by the Select data below. + const originOptions = useMemo( + () => + [...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[], + [allSchedules], + ); + const destinationOptions = useMemo( + () => + [ + ...new Set(allSchedules.map((s) => s.destination).filter(Boolean)), + ].sort() as string[], + [allSchedules], + ); + const filtered = useMemo(() => { const query = search.trim().toLowerCase(); - return allSchedules.filter((s) => { + const matched = allSchedules.filter((s) => { if (statusFilter !== "ALL" && s.status !== statusFilter) return false; if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false; + if (originFilter !== "ALL" && s.origin !== originFilter) return false; + if (destinationFilter !== "ALL" && s.destination !== destinationFilter) + return false; if (!query) return true; const haystack = [ + s.reference, s.trainNumber, s.routeName, s.origin, @@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() { .toLowerCase(); return haystack.includes(query); }); - }, [allSchedules, search, statusFilter, freightFilter]); + + const dir = sortDir === "asc" ? 1 : -1; + const sorted = [...matched].sort((a, b) => { + let cmp = 0; + if (sortBy === "reference") { + cmp = (a.reference ?? "").localeCompare(b.reference ?? ""); + } else { + // createdAt or scheduleDate — compare as timestamps (missing sorts last). + const av = new Date(a[sortBy] ?? 0).getTime(); + const bv = new Date(b[sortBy] ?? 0).getTime(); + cmp = av - bv; + } + return cmp * dir; + }); + return sorted; + }, [ + allSchedules, + search, + statusFilter, + freightFilter, + originFilter, + destinationFilter, + sortBy, + sortDir, + ]); const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize)); const paged = useMemo(() => { @@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ + { + id: "reference", + header: "Ref", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + {row.original.reference ?? "—"} + + ), + }, { id: "date", header: "Departure", @@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() { w={140} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} /> + setDestinationFilter(v ?? "ALL")} + data={[ + { value: "ALL", label: "All destinations" }, + ...destinationOptions.map((d) => ({ value: d, label: d })), + ]} + w={170} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> + ({ + data={options.map((o) => ({ value: o.value, label: o.label, }))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index eac2d36a8..b0a9c0a0d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -129,7 +129,9 @@ export const contractFormSchema = z contractType: z.enum(["new", "renewal"], "Select a contract type."), previousContractRef: z.string().default(""), - serviceTypeId: z.string("Select a service type."), + serviceTypeId: z + .string("Select a service type.") + .min(1, "Select a service type."), paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."), firstMile: z @@ -220,6 +222,14 @@ export const contractFormSchema = z }, ) .superRefine((data, ctx) => { + // Intercity (domestic) contracts are priced and invoiced in ETB only. + if (data.operationType === "intercity" && data.paymentCurrency !== "ETB") { + ctx.addIssue({ + code: "custom", + path: ["paymentCurrency"], + message: "Intercity contracts are priced in ETB.", + }); + } if (data.cargoType === "container") { // Container scope: at least one enabled size. if (data.enabledContainerSizes.length === 0) { @@ -252,29 +262,10 @@ export const contractFormSchema = z }); } } - // GENERAL contracts must carry a real (> 0) quantity cap — an untouched - // NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the - // Cargo & Route step until the customer enters a quantity. - if (data.contractKind === "general_contract") { - if (data.cargoType === "container") { - for (const size of data.enabledContainerSizes) { - if (!(data.containerSizeCaps[size] > 0)) { - ctx.addIssue({ - code: "custom", - path: ["containerSizeCaps", size], - message: `Enter a ${size} quantity greater than 0.`, - }); - } - } - } - if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) { - ctx.addIssue({ - code: "custom", - path: ["bulkQuantityCap"], - message: "Enter a total quantity greater than 0.", - }); - } - } + // GENERAL contracts are uncapped: no quantity cap is collected, so the + // customer can book repeatedly until the contract's validity expires. The + // cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at + // the API. No cap validation is applied. }); export type ContractFormValues = z.infer; @@ -286,7 +277,8 @@ export const initialContractFormValues: DeepPartial = { previousContractRef: "", serviceTypeId: "", - paymentCurrency: "USD", + // No preselected currency — the customer must choose (intercity forces ETB). + paymentCurrency: undefined, firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null }, lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null }, equipmentReturn: "with_return", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 817e6c56e..866104cda 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -336,6 +336,16 @@ export function Step2ServiceType({ } }, [operationType, standaloneServices, form]); + // Intercity (domestic) contracts are priced in ETB only — force the currency + // and let the field render just the ETB option. Also clears a stale USD from + // a restored draft or an operation-type switch. + const isIntercity = operationType === "intercity"; + useEffect(() => { + if (isIntercity && form.getValues("paymentCurrency") !== "ETB") { + form.setValue("paymentCurrency", "ETB", { shouldValidate: true }); + } + }, [isIntercity, form]); + return ( - + {showServiceSections && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index d278c15ea..51b9bcc93 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -5,7 +5,6 @@ import { Box, Group, MultiSelect, - NumberInput, Select, Skeleton, Stack, @@ -57,8 +56,6 @@ export function Step3CargoScope({ const cargoType = form.watch("cargoType"); const cargoTypePath = form.watch("cargoTypePath") ?? []; const parentId = cargoTypePath[0]; - const isGeneral = form.watch("contractKind") === "general_contract"; - const enabledSizes = form.watch("enabledContainerSizes") ?? []; // Reset the commodity child only when the parent group really changes. const prevParentIdRef = useRef(parentId); @@ -224,63 +221,9 @@ export function Step3CargoScope({ )} - {/* GENERAL contract quantity cap (draw-down ceiling). */} - {isGeneral && ( - - Booking quantity cap * - - Total quantity bookable across all shipments under this contract. - Customers / GL can book repeatedly until it is reached. Must be - greater than 0. - - {cargoType === "container" ? ( - - {enabledSizes.length === 0 ? ( - - Select container sizes above to set their caps. - - ) : ( - enabledSizes.map((size) => ( - ( - field.onChange(Number(v) || 0)} - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - /> - )} - /> - )) - )} - - ) : ( - ( - field.onChange(Number(v) || 0)} - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - /> - )} - /> - )} - - )} + {/* GENERAL contracts are uncapped — no quantity cap is collected. The + customer / GL can book repeatedly against the contract until its + validity expires (backend stores quantityCap = NULL = uncapped). */} {/* Shared billing flags. */} diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index cbdae37e3..26f57e506 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -60,7 +60,7 @@ async function bootstrap() { Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM. ## Latest Updates -- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities. +- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Luggage, Packages, and comprehensive CRUD operations across all entities. - **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting. - **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt. - **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers. @@ -328,7 +328,7 @@ Payment providers send notifications to: "JWT-auth", ) .addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation") - .addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.") + .addTag("Excess Luggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.") .addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.") .addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index a2ea23e09..50d953010 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -17,6 +17,7 @@ function generateRef(): string { return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } + /** * For package round-trip bookings, totalMinor in the DB may have been stored as a * single-leg amount before the server fix. Recompute from the tier price when needed. diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 1025a42b3..5d773860c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -14,7 +14,7 @@ const BOOKING_CUTOFF_MS = 30 * 60 * 1000; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); } // Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index e65a6be68..8d01e7113 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -17,7 +17,7 @@ class UpsertBaggageAllowanceDto { } // ── IAM-protected agent/supervisor routes ──────────────────────────────────── -@ApiTags('Excess Baggage') +@ApiTags('Excess Luggage') @Controller('agents/excess-baggage') @UseGuards(IamJwtGuard) @ApiBearerAuth('IAM-auth') @@ -100,7 +100,7 @@ export class ExcessBaggageAgentController { } // ── Public pay-by-token routes (passenger self-service) ────────────────────── -@ApiTags('Excess Baggage') +@ApiTags('Excess Luggage') @Controller('excess-baggage') export class ExcessBaggagePublicController { constructor(private service: ExcessBaggageService) {} diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 70f18564d..515a00655 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -30,13 +30,10 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL const nationalityUpper = (dto.nationality ?? '').toUpperCase(); const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; - // Find the nationality-specific seat class for the same coach type and bed position. - // Falls back to the requested seatClass if no nationality-specific one exists. const nationalitySeatClass = await this.prisma.seatClass.findFirst({ where: { coachTypeId: seatClass.coachTypeId, @@ -46,13 +43,10 @@ export class FareEngineService { }, }) ?? seatClass; - // Calculate distance: distanceKm represents cumulative distance from route origin - // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); - // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate const now = new Date(); const [originStation, destStation] = await Promise.all([ this.prisma.station.findUnique({ where: { id: dto.originStationId } }), @@ -81,8 +75,12 @@ export class FareEngineService { let baseFarePerPassengerMinor: number; let ratePerKmMinor: number; let fareSource: string; + let insuranceFactor = 1; + let usdToEtbRate = 1; + // When insuranceFeeMinor is used as a multiplier in the formula it must not + // be added again as a flat fee. This flag tracks that. + let insuranceAlreadyInBase = false; - // 1. Segment override: exact origin→destination stop pair on this route const segmentOverride = await this.prisma.segmentFareRule.findFirst({ where: { routeId: route.id, @@ -106,39 +104,46 @@ export class FareEngineService { }); if (segmentOverride) { - // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate baseFarePerPassengerMinor = segmentOverride.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SEGMENT_FARE_RULE'; } else if (fareRule?.tripId) { - // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Default: distance-based using tariff formula: km × rate × 1.02 - // baseFareMinor stores the per-km rate (tariff decimal × 100000) + // Distance-based formula: + // baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate + // baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km. + // insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset. + // usdToEtbRate fetched live from CurrencyExchangeRate table. + // Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee. + const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100; + insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0 + ? nationalitySeatClass.insuranceFeeMinor / 100 + : 1; + usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB); ratePerKmMinor = nationalitySeatClass.baseFareMinor; - baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); + baseFarePerPassengerMinor = Math.round( + totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate, + ); fareSource = 'SEAT_CLASS_BASE_FARE'; + insuranceAlreadyInBase = true; } - // Premium and insurance fees applied per passenger - const premiumPerPassenger = seatClass.premiumMinor ?? 0; - const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0; - const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; + const premiumPerPassenger = seatClass.premiumMinor ?? 0; + const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0); + const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); - // Subtotal includes: (distance-based fare + premium + insurance) × passengers - // First child is free, but pays premium and insurance - const adultSubtotal = farePerPassengerMinor * adultCount; + const adultSubtotal = farePerPassengerMinor * adultCount; const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; - const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; + const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; let discountMinor = 0; let promoLabel = 'none'; @@ -152,8 +157,7 @@ export class FareEngineService { } } - const afterDiscountMinor = subtotalMinor - discountMinor; - const totalEtbMinor = afterDiscountMinor; + const totalEtbMinor = subtotalMinor - discountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -162,8 +166,10 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, - `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, + `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`, + `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`, + `USD→ETB rate: ${usdToEtbRate}`, + `Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -191,6 +197,8 @@ export class FareEngineService { seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, + insuranceFactor, + usdToEtbRate, baseFarePerPassengerMinor, premiumPerPassenger, insurancePerPassenger, @@ -323,19 +331,16 @@ export class FareEngineService { if (fareRules.length > 0) { const billingCurrency = resolveCurrencyFromNationality(nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); - return fareRules.map(rule => { - const seatClassId = rule.seatClassId; - return { - seatClassId, - seatClassName: 'Unknown', - baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, - billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), - exchangeRate, - source: 'FARE_RULE', - }; - }); + return fareRules.map(rule => ({ + seatClassId: rule.seatClassId, + seatClassName: 'Unknown', + baseFareMinor: rule.baseFareMinor, + totalMinor: rule.baseFareMinor, + billingCurrency, + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + exchangeRate, + source: 'FARE_RULE', + })); } throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/search/search.module.ts b/apps/edr-passenger-api/src/modules/search/search.module.ts index baadcf90c..b7788c2fe 100644 --- a/apps/edr-passenger-api/src/modules/search/search.module.ts +++ b/apps/edr-passenger-api/src/modules/search/search.module.ts @@ -4,9 +4,10 @@ import { SearchService } from './search.service'; import { CurrencyModule } from '../currency/currency.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { SegmentsModule } from '../segments/segments.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; @Module({ - imports: [CurrencyModule, FareEngineModule, SegmentsModule], + imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 355446544..a02c7b7e3 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -6,6 +6,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; +import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; const POINTS_TO_MINOR = 10; @@ -40,8 +41,13 @@ export class SearchService { private currencyService: CurrencyService, private fareEngine: FareEngineService, private segmentsService: SegmentsService, + private systemConfig: SystemConfigService, ) {} + private async getCutoffHours(): Promise { + return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE); + } + async searchTrips(dto: SearchTripsDto) { const [direct, transit] = await Promise.all([ this.searchSchedules( @@ -166,13 +172,14 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, + status: 'SCHEDULED', isPackageOnly: false, OR: [ { departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, ], stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, @@ -183,7 +190,7 @@ export class SearchService { this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) ) ); - return results.filter(Boolean); + return results.filter((r): r is NonNullable => !!r && r.hasAvailability); } private async searchSchedules( @@ -200,12 +207,17 @@ export class SearchService { const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); + const cutoffHours = await this.getCutoffHours(); + const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000); + const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime())); + const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, + status: 'SCHEDULED', isPackageOnly: false, - departureAt: { gte: date < now ? now : date, lt: nextDay }, + departureAt: { gte: earliest, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }); @@ -215,7 +227,7 @@ export class SearchService { this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) ) ); - return results.filter(Boolean); + return results.filter((r): r is NonNullable => !!r && r.hasAvailability); } // ── Transit search ───────────────────────────────────────────────────────── @@ -241,26 +253,31 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, + status: 'SCHEDULED', isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), this.prisma.trainSchedule.findMany({ where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, + status: 'SCHEDULED', isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, + coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), ]); + const cutoffHours = await this.getCutoffHours(); + const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000); + const results: any[] = []; - for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) { const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; @@ -354,6 +371,9 @@ export class SearchService { .map((s: any) => s.id as string) ); + // Exclude schedules with no seats at all + if (allValidSeatIds.length === 0) return null; + // Run availability batch and fare calculation in parallel const [freeSeats, faresByClass] = await Promise.all([ this.segmentsService.getFreeSeatIds( diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index 63adefe5a..4404c3ea8 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -17,6 +17,8 @@ import { JwtGuard } from '../../common/jwt.guard'; import { CreateConversationDto, CreateGuestConversationDto, + DeviceIdBodyDto, + DeviceSendMessageDto, GuestIdBodyDto, GuestSendMessageDto, ListConversationsQueryDto, @@ -103,7 +105,39 @@ export class SupportController { return this.service.unreadCount('USER', { iamUserId: userId(req) }); } - // ---- customer: guest (unauthenticated) -------------------------------- + // ---- customer: device-scoped single thread (portal) ------------------- + // No auth, no forms. One conversation per device id (localStorage). Anyone + // with the device id can see that thread — accepted MVP trade-off. + + @Get('device/thread') + @IsPublic() + @ApiOperation({ summary: "Get the device's support thread + messages" }) + deviceThread(@Query('deviceId') deviceId: string) { + return this.service.getDeviceThread(deviceId); + } + + @Post('device/messages') + @IsPublic() + @ApiOperation({ summary: 'Send a message (creates the thread on first send)' }) + deviceSend(@Body() body: DeviceSendMessageDto) { + return this.service.sendDeviceMessage(body.deviceId, body.text); + } + + @Post('device/read') + @IsPublic() + @ApiOperation({ summary: 'Mark the device thread read' }) + deviceRead(@Body() body: DeviceIdBodyDto) { + return this.service.markDeviceRead(body.deviceId); + } + + @Get('device/unread-count') + @IsPublic() + @ApiOperation({ summary: "Count the device thread's unread messages" }) + deviceUnread(@Query('deviceId') deviceId: string) { + return this.service.unreadCount('USER', { guestId: deviceId }); + } + + // ---- customer: guest (unauthenticated, multi-ticket) ------------------ // No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer // of access — anyone with it sees that thread; accepted MVP trade-off). diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts index 77a3ee3ed..a60ac607e 100644 --- a/apps/edr-passenger-api/src/modules/support/support.dto.ts +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -93,6 +93,26 @@ export class GuestIdBodyDto { guestId!: string; } +export class DeviceSendMessageDto { + @ApiProperty({ description: 'Client device id (localStorage).' }) + @IsString() + @Length(8, 120) + deviceId!: string; + + @ApiProperty({ description: 'Message text.' }) + @IsString() + @MinLength(1) + @MaxLength(4000) + text!: string; +} + +export class DeviceIdBodyDto { + @ApiProperty() + @IsString() + @Length(8, 120) + deviceId!: string; +} + export class UpdateStatusDto { @ApiProperty({ enum: SupportStatusDto }) @IsEnum(SupportStatusDto) diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index dfbbb3b12..77f701bf2 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -113,6 +113,61 @@ export class SupportService { return this.firstMessage(conversation, input.initialMessage); } + // ---- customer: device-scoped single thread (portal) ------------------- + + /** The device's single conversation + its messages ({conversation:null} if none). */ + async getDeviceThread(deviceId: string): Promise { + if (!deviceId) return { conversation: null, messages: [] }; + const c = (await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + })) as ConversationRow | null; + if (!c) return { conversation: null, messages: [] }; + const rows = await this.prisma.supportMessage.findMany({ + where: { conversationId: c.id }, + orderBy: { createdAt: 'asc' }, + }); + const unread = await this.computeUnread([c], 'USER'); + return { + conversation: this.toConversationDto(c, unread.get(c.id) ?? 0), + messages: rows.map((m) => this.toMessageDto(m)), + }; + } + + /** Append a message to the device's thread, creating it on first message. */ + async sendDeviceMessage( + deviceId: string, + text: string, + ): Promise { + let c = (await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + })) as ConversationRow | null; + if (!c) { + c = (await this.prisma.supportConversation.create({ + data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' }, + })) as ConversationRow; + } + const updated = await this.appendMessage(c, 'USER', text); + const last = updated.messages[updated.messages.length - 1]; + return this.toMessageDto(last); + } + + /** Mark the device's thread read (customer side). */ + async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> { + const c = await this.prisma.supportConversation.findFirst({ + where: { guestId: deviceId }, + orderBy: { createdAt: 'asc' }, + }); + if (c) { + await this.prisma.supportConversation.update({ + where: { id: c.id }, + data: { userLastReadAt: new Date() }, + }); + } + return this.unreadCount('USER', { guestId: deviceId }); + } + async listForCustomer( owner: CustomerOwner, query: ListQuery, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts index 7f6ad7f52..96b3e2ef8 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts @@ -72,9 +72,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); + expect(result.successful).toBe(1); expect(result.failed).toBe(0); - expect(result.duplicate).toBe(0); }); it('should detect duplicate validations', async () => { @@ -98,8 +97,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); - expect(result.duplicate).toBe(1); + expect(result.successful).toBe(1); + expect(result.failed).toBe(1); }); it('should handle already validated tickets', async () => { @@ -119,8 +118,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.duplicate).toBe(1); - expect(result.success).toBe(0); + expect(result.successful).toBe(0); + expect(result.failed).toBe(1); }); }); }); diff --git a/apps/edr-passenger-web/backoffice/public/docs.md b/apps/edr-passenger-web/backoffice/public/docs.md index 0a4922fa9..428c365ae 100644 --- a/apps/edr-passenger-web/backoffice/public/docs.md +++ b/apps/edr-passenger-web/backoffice/public/docs.md @@ -34,7 +34,7 @@ The Passenger Backoffice Application is a comprehensive management system for th - **Live Tracking**: Monitor trip status and real-time updates - **Security Monitoring**: Fraud detection and audit logging - **Comprehensive Analytics**: Revenue, occupancy, and performance reports -- **🆕 Excess Baggage Management**: Handle boarding baggage charges with agent tools +- **🆕 Excess Luggage Management**: Handle boarding baggage charges with agent tools - **🆕 Travel Packages**: Manage pilgrimage and group travel packages with tiered pricing - **🆕 System Health Monitoring**: Real-time API health checks and system status - **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules @@ -94,7 +94,7 @@ The application is organized into 8 main sections: ├── System Config └── Settings └── Enhanced Features - ├── Excess Baggage + ├── Excess Luggage ├── Travel Packages ├── Package Inquiries ├── Health Monitoring @@ -2862,7 +2862,7 @@ Action: Block user ### Version 1.0.0 (January 15, 2026) - **Complete Platform Release** - Full-featured passenger management system -- **Excess Baggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options +- **Excess Luggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options - **Travel Packages** - Pilgrimage and group travel packages with tiered pricing, capacity management, and inquiry handling - **System Health Monitoring** - Real-time API health checks with liveness, readiness, and performance metrics - **System Configuration** - Centralized config management with feature flags, rate limiting, and operational controls @@ -2920,7 +2920,7 @@ Action: Block user ## Enhanced Features -### Excess Baggage +### Excess Luggage **Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay **Access Level**: Agent, Supervisor, Admin @@ -2941,7 +2941,7 @@ Action: Block user └──────────────────────────────┘ ``` -#### Excess Baggage Process +#### Excess Luggage Process 1. **At Boarding**: Agent weighs passenger baggage 2. **If Excess**: Agent creates charge in system @@ -2962,8 +2962,8 @@ Action: Block user ##### READ (List Charges) -1. **Access Excess Baggage Page**: - - Click **Excess Baggage** in Enhanced Features section +1. **Access Excess Luggage Page**: + - Click **Excess Luggage** in Enhanced Features section - Shows all baggage charges 2. **Search & Filter**: @@ -3000,7 +3000,7 @@ Action: Block user #### Agent Workflow -1. **Weigh Baggage**: Use station scales +1. **Weigh Luggage**: Use station scales 2. **Check Allowance**: Compare to passenger's seat class allowance 3. **Create Charge**: If excess weight found 4. **Offer Payment Options**: diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 348268ca2..c6d9af92b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -311,41 +311,25 @@ export default function ClassesPage() {

Per-km distance-based fare rate

-
-
- - -

Flat fee per passenger (e.g., lounge access, extra legroom)

-
- -
- - -

Flat fee per passenger (e.g., travel insurance)

-
+ +
+ + +

Flat fee per passenger (e.g., travel insurance)

Total Fare Calculation:

-

Total = (Base Fare × Distance) + Premium + Insurance

-

• Premium applies per passenger

-

• Insurance applies per passenger

+

Total = (Base Fare × Distance) + Insurance

+

• Insurance applies per passenger

diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 4cf393713..cee6944f6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -220,13 +220,6 @@ export default function CurrenciesPage() { )} -
-

How it works

-

• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)

-

• DJF and USD rates are used to display prices to passengers in their preferred currency

-

• Rates apply globally; changes take effect immediately on the next booking or fare quote

-
- { setShowAddModal(false); setError(null); }} diff --git a/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx b/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx index 7c14cc357..0f9b9beb5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/docs/sections/OperationsSection.tsx @@ -82,7 +82,7 @@ export default function OperationsSection() { {/* EXCESS BAGGAGE */}
-

📦 Luggage (Excess Baggage)

+

📦 Luggage (Excess Luggage)

Handle excess baggage charges at boarding — passenger self-pay or agent cash collection. Access level: Agent, Supervisor, Admin.

{['PENDING','PAID','CASH_COLLECTED','EXPIRED','WAIVED'].map(s => ( @@ -91,7 +91,7 @@ export default function OperationsSection() {
-

📦 How-To: Handle Excess Baggage

+

📦 How-To: Handle Excess Luggage

  1. Click Luggage in Operations
  2. Search by booking reference; filter by status or date
diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx index 3e221839b..73ce87383 100644 --- a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -609,7 +609,7 @@ export default function PricingPage() { className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground' }`} > - Excess Baggage Rates + Excess Luggage Rates
@@ -765,24 +765,6 @@ export default function PricingPage() {
-
-

Pricing Structure

-
    -
  • - • Segment Fares: Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa) -
  • -
  • - • Schedule Fares: Set custom pricing for each schedule by seat class and passenger type -
  • -
  • - • Passenger Type: ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare -
  • -
  • - • Nationality-based: Override fares for specific nationalities (Ethiopian, Djiboutian, Other) -
  • -
-
- {/* Delete Confirmation */}
- {/* Baggage Allowance Modal */} + {/* Luggage Allowance Modal */} { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
{baggageError &&
{baggageError}
} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index a5ed35666..413383b7a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -52,15 +52,28 @@ export default function SeatsPage() { queryFn: async () => { if (!selectedRoute) return null; const template: any[] = await routeCoachTemplatesApi.get(selectedRoute); - if (!template?.length) return []; - const fullCoaches = await Promise.all( - template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id)) - ); - return fullCoaches.map((coach: any, i: number) => ({ + if (template?.length) { + const fullCoaches = await Promise.all( + template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id)) + ); + return fullCoaches.map((coach: any, i: number) => ({ + ...coach, + coachNumber: coach.number, + positionNumber: template[i].positionNumber, + seatArrangement: coach.arrangement, + })); + } + // No template — fetch coaches from the most recent schedule for this route + const schedules: any = await schedulesApi.getAll({ routeId: selectedRoute }); + const scheduleList: any[] = schedules?.items || schedules?.data || (Array.isArray(schedules) ? schedules : []); + if (!scheduleList.length) return []; + const latestSchedule = scheduleList[scheduleList.length - 1]; + const seatMap: any = await seatsApi.getSeatMap(latestSchedule.id); + return (seatMap?.coaches || []).map((coach: any, i: number) => ({ ...coach, - coachNumber: coach.number, - positionNumber: template[i].positionNumber, - seatArrangement: coach.arrangement, + coachNumber: coach.number ?? coach.coachNumber, + positionNumber: coach.positionNumber ?? i + 1, + seatArrangement: coach.arrangement ?? coach.seatArrangement, })); }, enabled: !!selectedRoute, diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index 8102cc556..246a6f4f9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -42,7 +42,7 @@ export default function SupportPage() { const { data, isLoading } = useConversations( status === 'ALL' ? { search } : { status, search }, ); - const items = data?.items ?? []; + const items = useMemo(() => data?.items ?? [], [data?.items]); useSupportSocket(true); diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index bc8f31eaf..7b5ff3102 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -237,16 +237,6 @@ export default function TariffRatesPage() {
- {/* Tariff reference card */} -
-

Official Tariff Formula

-

- Fare = KM × rate × 1.02 × ExchangeRate -

-

- Rate is stored as baseFareMinor = tariff_decimal × 100,000 (e.g. 0.03 → 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine. -

-
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 53ae0584a..275291415 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -884,11 +884,11 @@ export default function TicketsPage() { })()} - {/* Excess Baggage Modal */} + {/* Excess Luggage Modal */} { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }} - title="Log Excess Baggage" + title="Log Excess Luggage" size="sm" > {excessResult ? ( diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 9f3764227..7824cef07 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -444,7 +444,7 @@ export const packageInquiriesApi = { remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`), }; -// Excess Baggage API +// Excess Luggage API export const excessBaggageApi = { logCharge: (data: any) => apiClient.post('/agents/excess-baggage', data), getCharge: (id: string) => apiClient.get(`/agents/excess-baggage/${id}`), diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 77a18380f..66f91dd60 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,57 +1,106 @@ -'use client'; +"use client"; -import { useSearchParams, useRouter } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; -import { useBookingStore } from '@/lib/booking-store'; -import { Schedule } from '@/types'; -import { ArrowRight, Clock, Calendar, Users, ChevronLeft, Check, X, MapPin, Gift, Train, Bed, Armchair, Star } from 'lucide-react'; -import { format } from 'date-fns'; -import { formatTime, getTimePeriod } from '@/utils/format'; -import { useState, useEffect } from 'react'; +import { useSearchParams, useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { useBookingStore } from "@/lib/booking-store"; +import { Schedule } from "@/types"; +import { + ArrowRight, + Clock, + Calendar, + Users, + ChevronLeft, + Check, + X, + MapPin, + Gift, + Train, + Bed, + Armchair, + Star, +} from "lucide-react"; +import { format } from "date-fns"; +import { formatTime, getTimePeriod } from "@/utils/format"; +import { useState, useEffect } from "react"; export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); - const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = useBookingStore(); - const [selectedCoachTypes, setSelectedCoachTypes] = useState>({}); + const { setSelectedSchedule, setOutboundSchedule, setInboundSchedule } = + useBookingStore(); + const [selectedCoachTypes, setSelectedCoachTypes] = useState< + Record + >({}); const [outboundScheduleData, setOutboundScheduleData] = useState( () => useBookingStore.getState().outboundSchedule, ); const [classModal, setClassModal] = useState(null); - const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); - const [roundTripStep, setRoundTripStep] = useState<'outbound' | 'inbound'>(() => { - const { outboundSchedule, searchCriteria: sc } = useBookingStore.getState(); - return outboundSchedule && sc?.tripType === 'ROUND_TRIP' ? 'inbound' : 'outbound'; - }); + const [promoData, setPromoData] = useState<{ + code: string; + discount: string; + message: string; + } | null>(null); + const [roundTripStep, setRoundTripStep] = useState<"outbound" | "inbound">( + () => { + const { outboundSchedule, searchCriteria: sc } = + useBookingStore.getState(); + return outboundSchedule && sc?.tripType === "ROUND_TRIP" + ? "inbound" + : "outbound"; + }, + ); const searchCriteria = useBookingStore((s) => s.searchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const searchData = { - originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '', - destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '', - date: searchParams.get('date') || searchCriteria?.departureDate || '', - returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate, - journeyType: (searchParams.get('tripType') ?? searchCriteria?.tripType ?? 'ONE_WAY') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', - adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1, - childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0, - nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN', - promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '', + originStationId: + searchParams.get("origin") || searchCriteria?.originStationId || "", + destinationStationId: + searchParams.get("destination") || + searchCriteria?.destinationStationId || + "", + date: searchParams.get("date") || searchCriteria?.departureDate || "", + returnDate: searchParams.get("returnDate") || searchCriteria?.returnDate, + journeyType: + (searchParams.get("tripType") ?? + searchCriteria?.tripType ?? + "ONE_WAY") === "ROUND_TRIP" + ? "ROUND_TRIP" + : "ONE_WAY", + adultCount: + parseInt(searchParams.get("adults") || "") || + searchCriteria?.adultCount || + 1, + childCount: + parseInt(searchParams.get("children") || "") || + searchCriteria?.childCount || + 0, + nationality: + searchParams.get("nationality") || + searchCriteria?.nationality || + "ETHIOPIAN", + promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "", }; useEffect(() => { - if (searchParams.get('origin')) { + if (searchParams.get("origin")) { setSearchCriteria({ - tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP', - originStationId: searchParams.get('origin')!, - destinationStationId: searchParams.get('destination')!, - departureDate: searchParams.get('date')!, - returnDate: searchParams.get('returnDate') || undefined, - adultCount: parseInt(searchParams.get('adults') || '1'), - childCount: parseInt(searchParams.get('children') || '0'), - nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER', - promoCode: searchParams.get('promoCode') || '', + tripType: (searchParams.get("tripType") || "ONE_WAY") as + | "ONE_WAY" + | "ROUND_TRIP", + originStationId: searchParams.get("origin")!, + destinationStationId: searchParams.get("destination")!, + departureDate: searchParams.get("date")!, + returnDate: searchParams.get("returnDate") || undefined, + adultCount: parseInt(searchParams.get("adults") || "1"), + childCount: parseInt(searchParams.get("children") || "0"), + nationality: (searchParams.get("nationality") || "ETHIOPIAN") as + | "ETHIOPIAN" + | "DJIBOUTIAN" + | "OTHER", + promoCode: searchParams.get("promoCode") || "", }); } }, [searchParams, setSearchCriteria]); @@ -59,13 +108,13 @@ export default function ResultsPage() { useEffect(() => { if (searchData.promoCode) { apiClient - .post('/promos/validate', { code: searchData.promoCode }) + .post("/promos/validate", { code: searchData.promoCode }) .then((response: any) => { if (response.applicable || response.valid) { setPromoData({ code: searchData.promoCode, - discount: response.message || 'Discount applied', - message: response.message || 'Promo code applied successfully!', + discount: response.message || "Discount applied", + message: response.message || "Promo code applied successfully!", }); } }) @@ -90,8 +139,12 @@ export default function ResultsPage() { return `/booking/search?${params}`; }; - const { data: results, isLoading, error } = useQuery({ - queryKey: ['search', searchData], + const { + data: results, + isLoading, + error, + } = useQuery({ + queryKey: ["search", searchData], queryFn: async (): Promise => { const payload: any = { originStationId: searchData.originStationId, @@ -102,15 +155,13 @@ export default function ResultsPage() { nationality: searchData.nationality, journeyType: searchData.journeyType, }; - - if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) { + + if (searchData.journeyType === "ROUND_TRIP" && searchData.returnDate) { payload.returnDate = searchData.returnDate; } - - - const response = await apiClient.post('/search', payload) as any; - - + + const response = (await apiClient.post("/search", payload)) as any; + return response; }, enabled: !!searchData.originStationId && !!searchData.destinationStationId, @@ -118,20 +169,20 @@ export default function ResultsPage() { gcTime: 0, }); - const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; - + const isRoundTrip = searchData.journeyType === "ROUND_TRIP"; + // Handle both response formats: // 1. One-way: response can be array of schedules OR object with journeyType and outbound // 2. Round-trip: response has journeyType, outbound, inbound properties let outboundSchedules: Schedule[] = []; let inboundSchedules: Schedule[] = []; - + if (results) { - if (results.journeyType === 'ROUND_TRIP') { + if (results.journeyType === "ROUND_TRIP") { // Round trip response format outboundSchedules = results.outbound || []; inboundSchedules = results.inbound || []; - } else if (results.journeyType === 'ONE_WAY' && results.outbound) { + } else if (results.journeyType === "ONE_WAY" && results.outbound) { // One-way response format with outbound array outboundSchedules = results.outbound || []; } else if (Array.isArray(results)) { @@ -142,40 +193,68 @@ export default function ResultsPage() { outboundSchedules = results.data; } } - - // Alternatives are surfaced whenever a leg returns no exact-date results. - const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : []; - const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : []; - const requestedDate: string = (results && results.requestedDate) || searchData.date; - const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || ''; - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + // Alternatives are surfaced whenever a leg returns no exact-date results. + const alternativeOutbound: Schedule[] = + !!results && outboundSchedules.length === 0 + ? results?.alternativeOutbound || [] + : []; + const alternativeInbound: Schedule[] = + isRoundTrip && !!results && inboundSchedules.length === 0 + ? results?.alternativeInbound || [] + : []; + const requestedDate: string = + (results && results.requestedDate) || searchData.date; + const requestedReturnDate: string = + (results && results.requestedReturnDate) || searchData.returnDate || ""; + + const isOneWayNoOutbound = + !isRoundTrip && !!results && outboundSchedules.length === 0; // Round-trip: show results view if either leg has exact results OR alternatives. // One-way: need at least one outbound result. const hasResults = isRoundTrip - ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0) + ? outboundSchedules.length > 0 || + alternativeOutbound.length > 0 || + inboundSchedules.length > 0 || + alternativeInbound.length > 0 : outboundSchedules.length > 0; - const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { - setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); + const handleSelectCoachType = ( + scheduleId: string, + coachTypeId: string, + coachTypeCode: string, + coachTypeName: string, + seatClassName: string, + ) => { + setSelectedCoachTypes((prev) => ({ + ...prev, + [scheduleId]: { + id: coachTypeId, + code: coachTypeCode, + name: coachTypeName, + seatClassName, + }, + })); }; const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { - const scheduleId = schedule.scheduleId || schedule.id || ''; + const scheduleId = schedule.scheduleId || schedule.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; - + if (!selectedCoachType) { - alert('Please select a coach type before continuing'); + alert("Please select a coach type before continuing"); return; } // Find the coach type to get pricing info - const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.code); + const coachType = schedule.coachTypes?.find( + (ct) => ct.coachTypeCode === selectedCoachType.code, + ); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) + ? Math.min(...coachType.classes.map((c) => c.baseFareMinor)) : 0; - const fareCurrency = 'ETB'; + const fareCurrency = "ETB"; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -184,12 +263,13 @@ export default function ResultsPage() { const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, - origin: schedule.origin?.name || 'Origin', - destination: schedule.destination?.name || 'Destination', - originStationId: schedule.origin?.id || schedule.originStationId || '', - destinationStationId: schedule.destination?.id || schedule.destinationStationId || '', - departureTime: schedule.departureAt || schedule.departureTime || '', - arrivalTime: schedule.arrivalAt || schedule.arrivalTime || '', + origin: schedule.origin?.name || "Origin", + destination: schedule.destination?.name || "Destination", + originStationId: schedule.origin?.id || schedule.originStationId || "", + destinationStationId: + schedule.destination?.id || schedule.destinationStationId || "", + departureTime: schedule.departureAt || schedule.departureTime || "", + arrivalTime: schedule.arrivalAt || schedule.arrivalTime || "", duration: durationStr, baseFareAdult: minFare, baseFareChild: minFare, @@ -199,7 +279,8 @@ export default function ResultsPage() { selectedCoachTypeId: selectedCoachType.id, selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeName: selectedCoachType.name, - seatClassName: (selectedCoachType as any).seatClassName || selectedCoachType.name, + seatClassName: + (selectedCoachType as any).seatClassName || selectedCoachType.name, // Retained so the seat map's coach preview can price a switch to a different // coach type without needing a fresh API call. coachTypes: schedule.coachTypes || [], @@ -210,8 +291,8 @@ export default function ResultsPage() { setOutboundScheduleData(scheduleData); setOutboundSchedule(scheduleData); setClassModal(null); - setRoundTripStep('inbound'); - window.scrollTo({ top: 0, behavior: 'smooth' }); + setRoundTripStep("inbound"); + window.scrollTo({ top: 0, behavior: "smooth" }); return; } @@ -223,8 +304,8 @@ export default function ResultsPage() { // For one-way setSelectedSchedule(scheduleData); } - - router.push('/booking/auth-check'); + + router.push("/booking/auth-check"); }; // Shared "Choose Your Coach" drawer — used by both the normal results view and the @@ -233,118 +314,164 @@ export default function ResultsPage() { const renderClassModal = () => { if (!classModal) return null; - const scheduleId = classModal.scheduleId || classModal.id || ''; + const scheduleId = classModal.scheduleId || classModal.id || ""; const selectedCoachType = selectedCoachTypes[scheduleId]; const isOutbound = (classModal as any).isOutbound; // Dining coaches aren't bookable seat/bed classes — exclude them from selection. - const coachTypes = (classModal.coachTypes || []).filter((ct: any) => ct.coachTypeCode !== 'DPC'); + const coachTypes = (classModal.coachTypes || []).filter( + (ct: any) => ct.coachTypeCode !== "DPC", + ); const getCoachIcon = (typeName: string) => { const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; + if (lower.includes("soft") || lower.includes("vip")) return Star; + if (lower.includes("bed")) return Bed; return Armchair; }; return ( <> -
setClassModal(null)} /> -
setClassModal(null)} + /> +
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- +
+
+

+ Choose Your Coach +

+

+ + {classModal.trainNumber} + · + + {classModal.origin?.name} → {classModal.destination?.name} + +

+ +
-
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = + selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length + ? Math.min( + ...coachType.classes.map((c: any) => c.baseFareMinor), + ) + : 0; + const coachCurrency = "ETB"; + const CoachIcon = getCoachIcon(coachType.coachTypeName); - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

+
+ )} +
+ + ); + })} +
+ ) : ( +
+
+
+

+ No coach types available for this journey +

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

)}
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
+
-
-
-

{t('help.title')}

-

{t('help.subtitle')}

-
+
+ {/* Hero */} +
+

Help & FAQs

+

+ Find answers about booking, pricing, payments, and more. +

+
-
-
- setSearchTerm(e.target.value)} - /> - -
-
+ {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary text-sm" + /> +
+
-
-
- {searchTerm ? ( - <> - {filteredFAQs.length > 0 ? ( - filteredFAQs.map((faq) => ( -
-
- {faq.question} -
-
{faq.answer}
-
- )) - ) : ( -
- No FAQs found for "{searchTerm}" -
- )} - - ) : ( - faqCategories.map((category, catIdx) => ( -
-

{category.title}

- {category.items.map((item, itemIdx) => { - const globalIdx = catIdx * 100 + itemIdx; - const isOpen = openIndexes.includes(globalIdx); + {/* Content */} +
+ {query ? ( + searchResults.length > 0 ? ( +
+

+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for “{searchTerm}” +

+ {searchResults.map(({ catTitle, item, key }) => ( + toggle(key)} + /> + ))} +
+ ) : ( +
+ +

No results for “{searchTerm}”

+
+ ) + ) : ( +
+ {FAQ_CATEGORIES.map((cat) => ( +
+
+ {cat.icon} +

{cat.title}

+
+
+ {cat.items.map((item, i) => { + const key = `${cat.title}-${i}`; return ( -
- - {isOpen &&
{item.answer}
} -
+ toggle(key)} + /> ); })}
- )) - )} +
+ ))}
-
+ )} +
-
-
-
- -
-

{t('help.help')}

-

{t('help.contact')}

- Contact Support + {/* Contact CTA */} +
+
+
+
-
-
- +

Still need help?

+

+ Our support team is available to assist you. +

+ + Contact Support + +
+ + + ); +} + +function FAQRow({ + question, + answer, + badge, + isOpen, + onToggle, +}: { + question: string; + answer: string; + badge?: string; + isOpen: boolean; + onToggle: () => void; +}) { + return ( +
+ + {isOpen && ( +
+ {answer} +
+ )} +
); } diff --git a/apps/edr-passenger-web/portal/src/components/Footer.tsx b/apps/edr-passenger-web/portal/src/components/Footer.tsx index 8d1aa1394..8d4e36a3f 100644 --- a/apps/edr-passenger-web/portal/src/components/Footer.tsx +++ b/apps/edr-passenger-web/portal/src/components/Footer.tsx @@ -1,7 +1,6 @@ 'use client'; import { Mail, Phone, MapPin } from 'lucide-react'; -import Link from 'next/link'; import { useLanguage, getTranslation, Language } from '@/lib/i18n'; import { useEffect, useState } from 'react'; @@ -21,83 +20,27 @@ export function Footer() {