diff --git a/apps/edr-freight-api/py/prod-check-invoices-payments.sql b/apps/edr-freight-api/py/prod-check-invoices-payments.sql new file mode 100644 index 000000000..fa94c481d --- /dev/null +++ b/apps/edr-freight-api/py/prod-check-invoices-payments.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- Production DB drift check + fix for the batch/window flow. +-- +-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which +-- queries freight.invoices.payments (a jsonb ledger added by migration +-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on +-- production (snapshot/restore drift — the migration can read as "applied" in +-- freight.migrations while the DDL never took effect), every reserve() throws +-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass, +-- and you see exactly: +-- * only ONE booking gets a pay window (the loop dies after the first reserve +-- whose invoice sync throws), and +-- * reservations never expire cleanly (the settle path hits the same query). +-- +-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2 +-- (idempotent, additive — safe to run even if partially applied). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing, +-- production has the drift and STEP 2 is required. +-- --------------------------------------------------------------------------- +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name IN ( + 'payments', 'subtotal_amount', 'tax_amount', + 'paid_amount', 'balance_amount', 'paid_at' + ) +ORDER BY column_name; +-- Also confirm the enum has the partial-payment statuses: +SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status; +-- Expect ISSUED and PARTIALLY_PAID to be present. + + +-- --------------------------------------------------------------------------- +-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns. +-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so +-- re-running is safe. Wrapped so the enum additions (which cannot run inside a +-- transaction block with immediate use) are applied first, then the columns. +-- --------------------------------------------------------------------------- + +-- Enum values (no-op if they already exist). +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING'; +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID'; + +-- Money-tracking + payments ledger columns. +ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + +-- Backfill derived money fields for existing rows (only rows not already set). +UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount + WHERE subtotal_amount = 0 AND balance_amount = 0; + +UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = COALESCE(paid_at, updated_at) + WHERE status = 'PAID' AND paid_amount = 0; + +-- --------------------------------------------------------------------------- +-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should +-- now be present. After this, deploy the freight_feature/usermanagement branch +-- and the batch will reserve ALL fitting bookings + expire non-payers + top up. +-- --------------------------------------------------------------------------- + + +-- ============================================================================ +-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid +-- invoices.payments can hide OTHER columns the batch flow selects. reserve() +-- and settleReserved() load the FULL Booking entity, so ANY missing booking +-- column throws mid-loop (e.g. we already hit +-- `column Booking.consolidation_resume_status does not exist`). This lists every +-- booking column the entity expects that is MISSING from production — expect +-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied. +-- ============================================================================ +WITH expected(col) AS ( + SELECT unnest(ARRAY[ + 'reference','customer_id','company_id','company_profile_id','is_government', + 'government_institution','train_id','status','contract_id','contract_route_id', + 'booking_type','contract_kind','created_by_role','created_by_user_id', + 'scheduled_date','estimated_shipment_date','expires_at','total_amount', + 'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason', + 'contract_validity_days','contract_valid_from','contract_valid_until', + 'payment_status','contract_type','service_type_id','customs_clearing_enabled', + 'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id', + 'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id', + 'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity', + 'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at', + 'pricing_breakdown','locked_at','priority_score','consolidation_partner_id', + 'consolidation_resume_status','wagons_required','scheduling_status', + 'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id', + 'loaded_at','arrived_at','payment_deadline','selected_for_batch_at', + 'gl_station_yard_id','clearance_current_phase','duty_required', + 'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason', + 'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at' + ]) +) +SELECT e.col AS missing_booking_column +FROM expected e +LEFT JOIN information_schema.columns c + ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col +WHERE c.column_name IS NULL +ORDER BY e.col; +-- If any rows come back, tell me which columns — I'll give you the exact +-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent). 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/2000000000000-CreateCompanyChangeRequest.ts b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts new file mode 100644 index 000000000..9d8d2b5c9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-CreateCompanyChangeRequest.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Staging table for customer profile edits that require backoffice review. An + * already-approved company's settings edits are snapshotted here (Pending) + * instead of being written to the live `companies` row; a reviewer approves + * (snapshot applied) or rejects with a note (customer amends & resubmits). + */ +export class CreateCompanyChangeRequest2000000000000 + implements MigrationInterface +{ + name = 'CreateCompanyChangeRequest2000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_change_request', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'snapshot', type: 'jsonb' }, + { name: 'documents', type: 'jsonb', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'note', type: 'text', isNullable: true }, + { name: 'submitted_by', type: 'uuid', isNullable: true }, + { name: 'submitted_at', type: 'timestamptz', isNullable: true }, + { name: 'reviewed_by', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedSchema: 'freight', + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.company_change_request', + new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }), + ); + await queryRunner.createIndex( + 'freight.company_change_request', + new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_change_request', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts new file mode 100644 index 000000000..2c2fdf3e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000001-AddCompanyProfileReview.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds reviewer note/id/timestamp to company_profiles so a rejected operational + * role (new ProfileStatus 'rejected') can carry the reason back to the customer, + * who can then amend and reapply. + */ +export class AddCompanyProfileReview2000000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileReview2000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('freight.company_profiles', [ + new TableColumn({ name: 'review_note', type: 'text', isNullable: true }), + new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }), + new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('freight.company_profiles', [ + 'review_note', + 'reviewed_by', + 'reviewed_at', + ]); + } +} 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/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts new file mode 100644 index 000000000..d3720de33 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-MigrateLicenseFilesToFileRecords.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Business-license files used to live inline as a jsonb array on + * `company_profiles.business_license_files`. They now belong to the FileRecord + * model (`freight.files`, resource `company_profiles`, code `business_license`) + * so they get stable ids and stream through `GET /api/files/:id` — the same + * proxy path regular documents use — instead of broken direct-MinIO URLs. + * + * This copies each existing inline entry into `freight.files` by reference + * (keeping the stored object URL — no bytes are re-uploaded). The original jsonb + * column is left intact for rollback safety. + */ +export class MigrateLicenseFilesToFileRecords2040000000000 + implements MigrationInterface +{ + name = "MigrateLicenseFilesToFileRecords2040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.files + (id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at) + SELECT + gen_random_uuid(), + cp.id, + 'company_profiles', + 'business_license', + COALESCE(elem->>'name', 'license'), + elem->>'url', + COALESCE(NULLIF(elem->>'size', '')::int, 0), + COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'), + now(), + now() + FROM freight.company_profiles cp + CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem + WHERE cp.business_license_files IS NOT NULL + AND jsonb_typeof(cp.business_license_files) = 'array' + AND elem->>'url' IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.files f + WHERE f.resource_id = cp.id + AND f.resource = 'company_profiles' + AND f.code = 'business_license' + AND f.url = elem->>'url' + AND f.deleted_at IS NULL + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse the model migration by dropping the license FileRecords. The + // original jsonb column was never cleared, so the data still exists there. + await queryRunner.query(` + DELETE FROM freight.files + WHERE resource = 'company_profiles' + AND code = 'business_license'; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 4df2a0eb3..037957367 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) { } function makeEvents() { - return { emit: jest.fn() }; + // BillingService emits via both emit() and emitAsync() (the post-commit async + // listener path) — the mock must provide both. + return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) }; } function generateInput(overrides: Record = {}) { @@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => { ], }, ); - expect(events.emit).toHaveBeenCalledWith( + expect(events.emitAsync).toHaveBeenCalledWith( "booking.invoice.paid", expect.objectContaining({ invoiceId: "inv-1", @@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => { expect(mg.update).not.toHaveBeenCalled(); expect(events.emit).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); }); }); @@ -257,6 +260,7 @@ describe("BillingService.recordPayment", () => { }), ); expect(events.emit).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); }); it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { @@ -268,7 +272,7 @@ describe("BillingService.recordPayment", () => { expect(updated.balanceAmount).toBe(0); expect(updated.paidAt).toBeInstanceOf(Date); expect(mg.update).toHaveBeenCalled(); - expect(events.emit).toHaveBeenCalledWith( + expect(events.emitAsync).toHaveBeenCalledWith( "warehouse.invoice.paid", expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), ); @@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => { expect(mg.update).not.toHaveBeenCalled(); }); }); + +/** + * Regression: `expirePayable` (batch settle path, called when a payment window + * lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE. + * The bug passed `dataSource.manager` (the non-transactional default) into the + * transition, so runTransition skipped opening a transaction and the lock threw + * `An open transaction is required for pessimistic lock` — aborting the whole + * settle pass (the "settle/reserve one booking at a time" symptom). The locked + * write MUST run inside dataSource.transaction. + */ +describe("BillingService.expirePayable — locked write runs in a transaction", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; + + const build = (lookupResult: Record | null) => { + const defaultManager = { + findOne: jest.fn().mockResolvedValue(lookupResult), + update: jest.fn().mockResolvedValue(undefined), + }; + const txManager = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const transaction = jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager)); + const events = makeEvents(); + const service = new BillingService( + { manager: defaultManager, transaction } as never, + {} as never, + {} as never, + events as never, + {} as never, + {} as never, + {} as never, + ); + return { service, defaultManager, txManager, transaction }; + }; + + it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => { + const { service, transaction, txManager, defaultManager } = build(openInvoice); + + await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(txManager.findOne).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ lock: { mode: "pessimistic_write" } }), + ); + expect(txManager.update).toHaveBeenCalled(); + // The default manager only does the initial lock-free lookup, never a locked read. + for (const call of defaultManager.findOne.mock.calls) { + expect(call[1]).not.toHaveProperty("lock"); + } + }); + + it("is a no-op (no transaction) when there is no open invoice", async () => { + const { service, transaction } = build(null); + + const result = await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + expect(result).toBeNull(); + expect(transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index a334b5e28..3e104c7ed 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -825,6 +825,13 @@ export class BillingService { type?: string, manager?: EntityManager, ): Promise { + // Lookup can use the default manager (no lock). But the pessimistic-lock write + // inside `transition` NEEDS an open transaction: pass the caller's `manager` + // through untouched (undefined when there is no caller txn) so `runTransition` + // opens its own. Passing `this.dataSource.manager` here made `runTransition` + // treat it as an already-open transaction and skip wrapping — the lock then + // threw `An open transaction is required for pessimistic lock`, aborting the + // whole settle pass (the "reservations settle/reserve one at a time" symptom). const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { @@ -842,7 +849,7 @@ export class BillingService { Freight.InvoiceStatus.Expired, "expired", {}, - mg, + manager, ); } 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.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..79d94f3de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -350,6 +350,21 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Patch(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + async updateCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.updateTruck(id, assignmentId, dto); + } + @Delete(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( 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..578e73278 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, }; } @@ -1399,6 +1424,18 @@ export class BookingsService { schedule?.status ?? null; } + // A generated-but-unsigned handover means the customer must approve delivery. + // Surfaced so the portal shows "Approve delivery" as soon as the handover + // exists, independent of the truck-arrival flag. + const [pendingHandover] = await this.dataSource.query( + `SELECT 1 FROM freight.booking_handovers + WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + LIMIT 1`, + [id], + ); + (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + Boolean(pendingHandover); + return booking; } @@ -1607,7 +1644,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..14402f830 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,21 +42,30 @@ 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'); } if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); + // Never assign more trucks than the booking has containers. + const existingTrucks = await this.dataSource + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (existingTrucks + 1 > bookingNumbers.length) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, + ); + } for (const n of requested) { if (!bookingNumbers.includes(n)) { throw new BadRequestException(`Container ${n} is not one of this booking's containers`); @@ -68,6 +77,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) => { @@ -133,6 +149,76 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Edit a truck assignment — plate/driver/type and the containers it carries. + * Allowed only until the truck has arrived (same guard as removal). Container + * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already + * on another truck, and a 40ft container fills the truck (max 1). + */ + async updateTruck( + bookingId: string, + assignmentId: string, + dto: AddCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot edit a truck that has already arrived'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + 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'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + // Exclude THIS truck's own containers so re-saving the same set is allowed. + const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (assignedElsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another 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) => { + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }); + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + }); + + return this.listTrucks(bookingId); + } + /** * Register an IMPORT self-haul truck leaving the port: the containers it * actually loaded (replacing any provisional list) and its weighed gross. @@ -244,7 +330,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 +342,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 +363,7 @@ export class CustomerTruckService { AND bcu.deleted_at IS NULL`, [bookingId, numbers], ); - return Number(row?.kg ?? 0); + return Number(row?.tons ?? 0); } /** @@ -399,4 +486,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/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index b1761b35f..f8fbb26b0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -12,6 +12,7 @@ import { HttpStatus, UseInterceptors, UploadedFiles, + BadRequestException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; @@ -33,7 +34,7 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { BusinessLicenseFile } from "./entities/company-profile.entity"; +import { ProfileLicenseFileView } from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; +import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; +import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -61,6 +64,25 @@ export class CompaniesController { private readonly filesService: FilesService, ) { } + /** + * License files are FileRecord-backed and previewed through `GET /api/files/:id` + * (the client builds that URL from the returned `id`). Populate each profile + * DTO's `licenseFiles` with its live/pending files in one batched lookup. + */ + private async populateLicenseFiles( + companyId: string, + profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[], + ): Promise { + if (profiles.length === 0) return; + const byProfile = await this.companiesService.assembleLicenseFilesByProfile( + companyId, + profiles.map((p) => p.id), + ); + for (const p of profiles) { + p.licenseFiles = byProfile[p.id] ?? []; + } + } + @Get("getInfo") @ApiOperation({ summary: "Get company info for the current user" }) async getInfo( @@ -68,7 +90,10 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new CompanyInfoResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return new CompanyInfoResponseDto(profile, company, review); } @Get("profile") @@ -78,7 +103,42 @@ export class CompaniesController { ): Promise { const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); - return new ProfileResponseDto(profile, company); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + const dto = new ProfileResponseDto(profile, company, review); + await this.populateLicenseFiles(company.id, dto.companyProfiles); + return dto; + } + + @Get("profile/change-request") + @ApiOperation({ + summary: "Current user's open profile change request (pending/rejected)", + }) + async getMyChangeRequest( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { company } = + await this.companiesService.getCompanyInfoByUserId(user.id); + const review = await this.companiesService.getOpenChangeRequestForCompany( + company.id, + ); + return review ? new ChangeRequestResponseDto(review) : null; + } + + @Post("company-profiles/:profileId/reapply") + @ApiOperation({ + summary: "Resubmit a rejected operational role for approval (→ pending)", + }) + async reapplyCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + ): Promise { + const profile = await this.companiesService.reapplyCompanyProfile( + user.id, + profileId, + ); + return new ResponseCompanyProfileDto(profile); } @Get("dashboard") @@ -177,28 +237,72 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Upload business-license document(s) for one of the current user's company profiles", + "Add business-license document(s) to a profile. For an approved company " + + "the upload is staged for backoffice review; during onboarding it goes live.", }) async uploadProfileLicense( @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, @UploadedFiles() files: Array, - ): Promise { - return this.companiesService.uploadProfileLicenseFiles( + ): Promise { + return this.companiesService.addProfileLicenseFiles( user.id, profileId, files, ); } + @Post("company-profiles/:profileId/license/:fileId/replace") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Replace a business-license file with a newly uploaded one (staged for " + + "review on an approved company).", + }) + async replaceProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + @UploadedFiles() files: Array, + ): Promise { + const file = files?.[0]; + if (!file) { + throw new BadRequestException("A replacement file is required"); + } + return this.companiesService.replaceProfileLicenseFile( + user.id, + profileId, + fileId, + file, + ); + } + + @Delete("company-profiles/:profileId/license/:fileId") + @ApiOperation({ + summary: + "Remove a business-license file (staged for review on an approved company).", + }) + async removeProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ): Promise { + return this.companiesService.removeProfileLicenseFile( + user.id, + profileId, + fileId, + ); + } + @Get("company-profiles/:profileId/license") @ApiOperation({ - summary: "List business-license documents for a company profile", + summary: "List business-license documents (with review state) for a profile", }) async listProfileLicense( @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, - ): Promise { + ): Promise { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } @@ -306,7 +410,9 @@ export class CompaniesController { @Param("id", ParseUUIDPipe) id: string, ): Promise { const company = await this.companiesService.findCompanyById(id); - return new ResponseCompanyDto(company); + const dto = new ResponseCompanyDto(company); + await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []); + return dto; } @Patch(":id") @@ -354,26 +460,76 @@ export class CompaniesController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( + @CurrentUser() user: CurrentIamUser, @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, "companies", files); + // Routed through the service so an approved company's uploads are staged for + // review (and lock the customer), while onboarding uploads pass straight through. + return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } @Patch("company-profiles/:profileId/status") @FreightAdmin() @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( + @CurrentUser() user: CurrentIamUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, + dto.note, + user.id, ); return new ResponseCompanyProfileDto(profile); } + @Get(":companyId/change-requests") + @FreightAdmin() + @ApiOperation({ summary: "List a company's profile change requests" }) + async listChangeRequests( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const requests = await this.companiesService.listChangeRequests(companyId); + return requests.map((r) => new ChangeRequestResponseDto(r)); + } + + @Post("change-requests/:id/approve") + @FreightAdmin() + @ApiOperation({ + summary: "Approve a pending profile change request (applies the changes)", + }) + async approveChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + const request = await this.companiesService.approveChangeRequest( + id, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + + @Post("change-requests/:id/reject") + @FreightAdmin() + @ApiOperation({ + summary: "Reject a pending profile change request with a note", + }) + async rejectChangeRequest( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectChangeRequestDto, + ): Promise { + const request = await this.companiesService.rejectChangeRequest( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @FreightAdmin() @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 42186dd8e..646d01d47 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; +import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; @Module({ imports: [ - TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + TypeOrmModule.forFeature([ + Company, + ExternalProfile, + CompanyProfile, + CompanyChangeRequest, + Booking, + ]), HttpModule, FilesModule, FileUploadSettingsModule, @@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service"; CompaniesRepository, ExternalProfileRepository, CompanyProfileRepository, + CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, ], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe679627b..e31851aef 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,13 +7,14 @@ import { } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, DashboardScope, } from "./company-dashboard.repository"; -import { MinioService } from "../minio/minio.service"; import { FilesService } from "../files/files.service"; +import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; @@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, ProfileType, ProfileStatus, } from "./entities/company-profile.entity"; +import { + ChangeRequestStatus, + CompanyChangeRequest, + LicenseChangeIntent, +} from "./entities/company-change-request.entity"; + +/** FileRecord `resource` + `code` slots for business-license documents. */ +const LICENSE_RESOURCE = "company_profiles"; +const LICENSE_CODE = "business_license"; +/** Code for a license file staged in an open change request (not yet live). */ +const LICENSE_PENDING_CODE = "business_license_pending"; export interface UserIdentity { userId: string; @@ -54,9 +67,9 @@ export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, + private readonly changeRequestRepo: CompanyChangeRequestRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, - private readonly minioService: MinioService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, @@ -334,28 +347,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); - for (const profile of company.companyProfiles) { - profile.businessLicenseFiles = await this.signLicenseFiles( - profile.businessLicenseFiles, - ); - } return company; } - /** - * Business-license files are stored as raw, unsigned MinIO URLs (see - * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them - * directly. Sign each one with a short-lived URL before it reaches a response. - */ - private async signLicenseFiles( - files?: BusinessLicenseFile[] | null, - ): Promise { - if (!files?.length) return []; - return Promise.all( - files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), - ); - } - /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff @@ -574,12 +568,24 @@ export class CompaniesService { return updated; } - async updateProfile( - userId: string, - dto: UpdateProfileDto, - ): Promise { - const { profile, company } = await this.getCompanyInfoByUserId(userId); + /** Keep only the keys that were actually provided (drop `undefined`). */ + private pickDefined(dto: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(dto)) { + if (v !== undefined) out[k] = v; + } + return out; + } + /** + * Translate an UpdateProfileDto (or a staged change-request snapshot) into a + * `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/ + * PoA live there). Pure — the caller runs the async TIN-uniqueness check. + */ + private mapProfileDtoToCompanyUpdates( + company: Company, + dto: Partial, + ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -593,21 +599,10 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined && dto.tin !== company.tin) { - // Reject a TIN already taken by a different company (the user's own draft - // placeholder is fine to overwrite). - const owner = await this.companiesRepo.findByTin(dto.tin); - if (owner && owner.id !== company.id) { - throw new ConflictException( - `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, - ); - } + if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; - } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) { - companyUpdates.fanNumber = dto.fanNumber; - } + if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -629,8 +624,7 @@ export class CompaniesService { if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) - attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; if (dto.licenceNumber !== undefined) @@ -653,11 +647,219 @@ export class CompaniesService { companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; + return companyUpdates; + } - const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - return new ProfileResponseDto(profile, updated); + /** Reject a TIN already registered to a *different* company. */ + private async assertTinAvailable( + company: Company, + tin: string | undefined, + ): Promise { + if (tin === undefined || tin === company.tin) return; + const owner = await this.companiesRepo.findByTin(tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `This TIN (${tin}) is already registered to another company. Please check the number and try again.`, + ); + } + } + + /** The company's open (pending or last-rejected) profile change request. */ + async getOpenChangeRequestForCompany( + companyId: string, + ): Promise { + return this.changeRequestRepo.findLatestOpenByCompanyId(companyId); + } + + /** + * Update the current user's profile. + * + * - Company not yet approved (onboarding) → write straight to the Company row, + * as before. The company/role pending→approve gate already covers first-run. + * - Company already `active` → do NOT touch the live Company. Stage the edit in + * a pending change request (merging into any open one) so a backoffice + * reviewer can approve (apply) or reject (with a note). This locks the + * customer until the review resolves. + */ + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + if (company.status !== CompanyStatus.Active) { + await this.assertTinAvailable(company, dto.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); + const updated = await this.companiesRepo.update( + company.id, + companyUpdates, + ); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + + // Approved company: stage the change for review, leaving the live row intact. + await this.assertTinAvailable(company, dto.tin); + const fields = this.pickDefined(dto); + + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + let request: CompanyChangeRequest; + if (existing) { + request = + (await this.changeRequestRepo.update(existing.id, { + snapshot: { ...(existing.snapshot ?? {}), ...fields }, + submittedBy: userId, + submittedAt: now, + note: null, + })) ?? existing; + } else { + request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot: fields, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + } + + // Live company is unchanged; surface the pending state for the settings page. + return new ProfileResponseDto(profile, company, request); + } + + /** List a company's change requests, newest first (backoffice review). */ + async listChangeRequests( + companyId: string, + ): Promise { + await this.findCompanyById(companyId); + return this.changeRequestRepo.findByCompanyId(companyId); + } + + /** + * Approve a pending change request: apply its snapshot to the live Company and + * mark the request approved. Any staged documents are already attached to the + * company, so nothing else needs promoting. + */ + async approveChangeRequest( + id: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + + const company = await this.companiesRepo.findById(request.companyId); + if (!company) + throw new NotFoundException(`Company ${request.companyId} not found`); + + const snapshot = (request.snapshot ?? {}) as Partial; + await this.assertTinAvailable(company, snapshot.tin); + const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); + await this.companiesRepo.update(company.id, companyUpdates); + await this.applyLicenseChanges(request); + + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Approved, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + note: null, + })) ?? request + ); + } + + /** + * Upload company documents. For an approved company this also opens/updates a + * pending change request (recording the uploaded file ids) so the upload is + * reviewed and the customer is locked until it clears — consistent with the + * field-edit review. During onboarding (company not yet active) it's a plain + * upload with no review. + */ + async uploadCompanyDocuments( + companyId: string, + files: Express.Multer.File[], + submittedBy?: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const uploaded = await this.filesService.uploadMany( + companyId, + "companies", + files, + ); + if (company.status === CompanyStatus.Active) { + await this.stageDocumentChange( + company.id, + uploaded.map((f) => f.id), + submittedBy, + ); + } + return uploaded; + } + + /** Open or append a pending change request recording staged document uploads. */ + private async stageDocumentChange( + companyId: string, + fileIds: string[], + submittedBy?: string, + ): Promise { + if (fileIds.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentFileIds ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { documentFileIds: [...prev, ...fileIds] }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentFileIds: fileIds }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** Reject a pending change request with a note (customer amends & resubmits). */ + async rejectChangeRequest( + id: string, + note: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + await this.discardLicenseChanges(request); + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.Rejected, + // Staged license uploads were just discarded; drop their intents so an + // amended resubmit never re-references deleted files. + documents: { ...request.documents, licenseChanges: [] }, + note, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + })) ?? request + ); } async deleteCompany(id: string): Promise { @@ -713,6 +915,8 @@ export class CompaniesService { async setCompanyProfileStatus( profileId: string, status: ProfileStatus, + note?: string, + reviewerId?: string, ): Promise { const existing = await this.companyProfilesRepo.findById(profileId); if (!existing) @@ -727,6 +931,18 @@ export class CompaniesService { ); } + // Track the review outcome. Rejection keeps the note so the customer knows + // why; approval clears it. Any decision stamps the reviewer + time. + if (status === ProfileStatus.Rejected) { + patch.reviewNote = note ?? null; + } else if (status === ProfileStatus.Active) { + patch.reviewNote = null; + } + if (status !== ProfileStatus.Pending) { + patch.reviewedBy = reviewerId ?? null; + patch.reviewedAt = new Date(); + } + const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); @@ -744,6 +960,41 @@ export class CompaniesService { return updated; } + /** + * Customer reapplies for a rejected operational role (after fixing whatever the + * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and + * clear the rejection note so it re-enters the approval queue. + */ + async reapplyCompanyProfile( + userId: string, + profileId: string, + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + const companyId = profile.company?.id ?? profile.companyId; + + const target = await this.companyProfilesRepo.findById(profileId); + if (!target || target.companyId !== companyId) { + throw new NotFoundException(`Company profile ${profileId} not found`); + } + if (target.status !== ProfileStatus.Rejected) { + throw new BadRequestException( + "Only a rejected role can be resubmitted for approval", + ); + } + + const updated = await this.companyProfilesRepo.update(profileId, { + status: ProfileStatus.Pending, + reviewNote: null, + reviewedBy: null, + reviewedAt: null, + }); + if (!updated) + throw new NotFoundException(`Company profile ${profileId} not found`); + return updated; + } + async createCompanyProfile( companyId: string, profileType?: ProfileType, @@ -833,12 +1084,12 @@ export class CompaniesService { ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference(type); + // Self-service role adds start Pending and carry no reference — a reference + // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -870,13 +1121,14 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { - const reference = await this.companyProfilesRepo.generateReference(type); + // New self-service roles start Pending (awaiting backoffice approval) and + // carry no reference until approved. The customer can select this mode but + // can't book under it until it's cleared. created = await this.companyProfilesRepo.create({ companyId, type, - reference, businessLicense: businessLicense ?? null, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -971,13 +1223,21 @@ export class CompaniesService { })); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); - // 3. Per-operational-profile business licenses. - const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ - profileId: p.id, - type: p.type, - reference: p.reference ?? "", - uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, - })); + // 3. Per-operational-profile business licenses (FileRecord-backed). + const licenseProfiles = await Promise.all( + (company.companyProfiles ?? []).map(async (p) => { + const records = await this.filesService.findByResource( + p.id, + LICENSE_RESOURCE, + ); + return { + profileId: p.id, + type: p.type, + reference: p.reference ?? "", + uploaded: records.some((r) => r.code === LICENSE_CODE), + }; + }), + ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); const outstanding = [ @@ -1094,61 +1354,321 @@ export class CompaniesService { return owned; } + // ─── Business-license files ──────────────────────────────────────────────── + // + // License documents live in the FileRecord model (`freight.files`) with + // `resource = "company_profiles"`, `resourceId = `. Live files use + // code `LICENSE_CODE`; files staged inside an open change request (add / + // replacement) use `LICENSE_PENDING_CODE` and only become live on approval. + // Preview streams through `GET /api/files/:id` (server-side proxy) — the same + // path regular documents use — so it never hits MinIO directly from the + // browser (which fails on the internal bucket endpoint). + /** - * Upload business-license document(s) and store them directly on the company - * profile (multi-file). Bytes go to object storage; only metadata/URLs are - * persisted on the profile — intentionally not via the FileRecord file model. - * New files are appended to any already present. Returns the full list. + * Upload business-license file(s) for one of the user's profiles. During + * onboarding (company not yet Active) they go live immediately; for an Active + * company they're staged under the pending code and recorded as `add` intents + * on a pending change request for backoffice review. Returns the updated view. */ - async uploadProfileLicenseFiles( + async addProfileLicenseFiles( userId: string, profileId: string, files: Express.Multer.File[], - ): Promise { + ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; - const uploaded: BusinessLicenseFile[] = []; - for (const file of files) { - const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; - const url = await this.minioService.uploadFile( - objectName, - file.buffer, - file.mimetype, + const uploaded = await Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code, + file, + }), + ), + ); + + if (gated) { + await this.stageLicenseChange( + company.id, + uploaded.map((r) => ({ + profileId, + op: "add" as const, + fileId: r.id, + fileName: r.name, + })), + userId, ); - uploaded.push({ - name: file.originalname, - url, - size: file.size, - mimeType: file.mimetype, - }); } - const next = [...(profile.businessLicenseFiles ?? []), ...uploaded]; - await this.companyProfilesRepo.update(profileId, { - businessLicenseFiles: next, - }); - return next; - } - - /** The business-license files stored on a single company profile. */ - async listProfileLicenseFiles( - userId: string, - profileId: string, - ): Promise { - const profile = await this.resolveOwnedProfile(userId, profileId); - return profile.businessLicenseFiles ?? []; + return this.getProfileLicenseView(profileId, company.id); } /** - * Onboarding documents stored on a company profile, fetched by profile id. - * Internal helper (no ownership check) used when a booking reuses the active - * profile's onboarding documents. Returns [] when the profile is unknown. + * Remove a license file. A staged (pending) file is withdrawn outright + * (soft-deleted, its `add` intent dropped). A live file on an Active company + * is kept and recorded as a `remove` intent for review; during onboarding it + * is deleted immediately. + */ + async removeProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== LICENSE_RESOURCE || + record.resourceId !== profileId + ) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + if (record.code === LICENSE_PENDING_CODE) { + // Withdraw a not-yet-approved upload: delete it and drop its add intent. + await this.filesService.remove(fileId); + await this.withdrawLicenseIntent(company.id, fileId); + } else if (gated) { + await this.stageLicenseChange( + company.id, + [{ profileId, op: "remove", fileId, fileName: record.name }], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** + * Replace a live license file with a freshly uploaded one — recorded as a + * `remove` of the old file plus an `add` of the new, so approval swaps them + * atomically. During onboarding the swap is applied immediately. + */ + async replaceProfileLicenseFile( + userId: string, + profileId: string, + fileId: string, + file: Express.Multer.File, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + const old = await this.filesService.findById(fileId); + if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) { + throw new NotFoundException(`License file ${fileId} not found`); + } + const company = await this.findCompanyById(profile.companyId); + const gated = company.status === CompanyStatus.Active; + + const created = await this.filesService.upload({ + resourceId: profileId, + resource: LICENSE_RESOURCE, + code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE, + file, + }); + + if (gated) { + await this.stageLicenseChange( + company.id, + [ + { profileId, op: "remove", fileId, fileName: old.name }, + { profileId, op: "add", fileId: created.id, fileName: created.name }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getProfileLicenseView(profileId, company.id); + } + + /** License files for one profile, with each file's review status resolved. */ + async listProfileLicenseFiles( + userId: string, + profileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + return this.getProfileLicenseView(profileId, profile.companyId); + } + + /** + * Live license files for a profile, shaped for by-reference reuse (bookings / + * contracts snapshot these). No ownership check — internal callers only. + * Returns the raw stored URLs; pending (unapproved) files are excluded. */ async getProfileOnboardingFiles( profileId: string, ): Promise { - const profile = await this.companyProfilesRepo.findById(profileId); - return profile?.businessLicenseFiles ?? []; + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter((r) => r.code === LICENSE_CODE) + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); + } + + /** + * Assemble the review-aware license view for a set of profiles in one pass + * (single change-request lookup). Used to enrich company/profile responses. + */ + async assembleLicenseFilesByProfile( + companyId: string, + profileIds: string[], + ): Promise> { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const result: Record = {}; + await Promise.all( + profileIds.map(async (pid) => { + result[pid] = await this.mapLicenseRecords(pid, removeIds); + }), + ); + return result; + } + + /** Single-profile license view (fetches the company's pending request once). */ + private async getProfileLicenseView( + profileId: string, + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.licenseChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + return this.mapLicenseRecords(profileId, removeIds); + } + + private async mapLicenseRecords( + profileId: string, + pendingRemoveIds: Set, + ): Promise { + const records = await this.filesService.findByResource( + profileId, + LICENSE_RESOURCE, + ); + return records + .filter( + (r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === LICENSE_PENDING_CODE + ? ("pending_add" as const) + : pendingRemoveIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording license add/remove intents. */ + private async stageLicenseChange( + companyId: string, + changes: LicenseChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.licenseChanges ?? []; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + licenseChanges: [...prev, ...changes], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { licenseChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged license intent (add or remove) referencing `fileId` from the + * company's open request. If that empties the request entirely, delete it so + * the customer's settings page unlocks. + */ + private async withdrawLicenseIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.licenseChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, licenseChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged license changes: promote adds, delete removes. */ + private async applyLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, LICENSE_CODE); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged license uploads (adds only). */ + private async discardLicenseChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.licenseChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } } /** diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts new file mode 100644 index 000000000..24d988452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -0,0 +1,55 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +@Injectable() +export class CompanyChangeRequestRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyChangeRequest) + repo: Repository, + ) { + super(repo); + } + + /** The company's current pending request, if any. */ + async findPendingByCompanyId( + companyId: string, + ): Promise { + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Pending }, + order: { createdAt: "DESC" }, + }); + } + + /** + * The company's latest "open" request — pending (locks the customer) or the + * most recent rejected one (drives the reapply banner + prefill). Approved + * requests are terminal and ignored here. + */ + async findLatestOpenByCompanyId( + companyId: string, + ): Promise { + const pending = await this.findPendingByCompanyId(companyId); + if (pending) return pending; + return this.repository.findOne({ + where: { companyId, status: ChangeRequestStatus.Rejected }, + order: { createdAt: "DESC" }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} 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/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts new file mode 100644 index 000000000..579ac6ddd --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -0,0 +1,44 @@ +import { + ChangeRequestStatus, + CompanyChangeRequest, + LicenseChangeIntent, +} from "../entities/company-change-request.entity"; + +/** + * A staged profile change request. Used both by the portal (to lock the settings + * page, show the reviewer note, and prefill the proposed values) and by the + * backoffice review screen (to render the proposed-vs-current diff). + */ +export class ChangeRequestResponseDto { + id: string; + companyId: string; + status: ChangeRequestStatus; + /** Proposed field values (Partial) — the diff payload. */ + snapshot: Record; + documentFileIds: string[]; + /** Staged business-license add/remove intents attached to this request. */ + licenseChanges: LicenseChangeIntent[]; + note: string | null; + submittedBy: string | null; + submittedAt: Date | null; + reviewedBy: string | null; + reviewedAt: Date | null; + createdAt: Date; + updatedAt: Date; + + constructor(req: CompanyChangeRequest) { + this.id = req.id; + this.companyId = req.companyId; + this.status = req.status; + this.snapshot = req.snapshot ?? {}; + this.documentFileIds = req.documents?.documentFileIds ?? []; + this.licenseChanges = req.documents?.licenseChanges ?? []; + this.note = req.note ?? null; + this.submittedBy = req.submittedBy ?? null; + this.submittedAt = req.submittedAt ?? null; + this.reviewedBy = req.reviewedBy ?? null; + this.reviewedAt = req.reviewedAt ?? null; + this.createdAt = req.createdAt; + this.updatedAt = req.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 04fd42816..9a4fb330a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -1,14 +1,43 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyDto } from './response-company.dto'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; export class CompanyInfoResponseDto { profile: ResponseExternalProfileDto; company: ResponseCompanyDto; + /** + * Open profile-edit review, if any. Drives the portal-wide lock (pending → + * settings + new-contract/booking creation disabled) and the reapply banner. + */ + review: { + status: 'pending' | 'rejected'; + note: string | null; + } | null; - constructor(profile: ExternalProfile, company: Company) { + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); + + const open = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.review = open + ? { + status: open.status as 'pending' | 'rejected', + note: open.note ?? null, + } + : null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89a52b5e6..89ab954e7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,5 +1,9 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from '../entities/company-change-request.entity'; import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { @@ -48,7 +52,20 @@ export class ProfileResponseDto { profileId: string; - constructor(profile: ExternalProfile, company: Company) { + /** + * Open profile-edit review, if any. `reviewStatus === "pending"` locks the + * settings page; `"rejected"` surfaces the note and prefills the (declined) + * proposed values from `pendingChanges` so the customer can amend & resubmit. + */ + reviewStatus: "pending" | "rejected" | null; + reviewNote: string | null; + pendingChanges: Record | null; + + constructor( + profile: ExternalProfile, + company: Company, + changeRequest?: CompanyChangeRequest | null, + ) { this.companyId = company.id; this.companyName = company.name; this.companyType = company.type; @@ -92,5 +109,20 @@ export class ProfileResponseDto { this.poaEmail = attrs.poaEmail ?? null; this.poaLocation = attrs.poaLocation ?? null; this.poaAddress = attrs.poaAddress ?? null; + + const openReview = + changeRequest && + (changeRequest.status === ChangeRequestStatus.Pending || + changeRequest.status === ChangeRequestStatus.Rejected) + ? changeRequest + : null; + this.reviewStatus = + openReview?.status === ChangeRequestStatus.Pending + ? "pending" + : openReview?.status === ChangeRequestStatus.Rejected + ? "rejected" + : null; + this.reviewNote = openReview?.note ?? null; + this.pendingChanges = openReview?.snapshot ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts new file mode 100644 index 000000000..c32b44a79 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/reject-change-request.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength, MinLength } from "class-validator"; + +export class RejectChangeRequestDto { + /** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */ + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + note!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 5d90d8d60..0c783cbcf 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -5,8 +5,8 @@ import { CompanyNationality, } from '../entities/company.entity'; import { - BusinessLicenseFile, CompanyProfile, + ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; @@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto { status: string; /** @deprecated Superseded by licenseFiles. Kept for back-compat. */ businessLicense?: string | null; - /** Business-license documents stored on the profile (multi-file). */ - licenseFiles: BusinessLicenseFile[]; + /** + * Business-license documents (FileRecord-backed) with review state. Left empty + * by the constructor and populated asynchronously by the controller, since the + * files and their pending-change status require DB lookups. + */ + licenseFiles: ProfileLicenseFileView[]; attributes?: Record | null; + /** Reviewer note when the role is rejected (drives the reapply prompt). */ + reviewNote?: string | null; createdAt: Date; updatedAt: Date; @@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto { this.reference = profile.reference ?? ''; this.status = profile.status; this.businessLicense = profile.businessLicense; - this.licenseFiles = profile.businessLicenseFiles ?? []; + this.licenseFiles = []; this.attributes = profile.attributes; + this.reviewNote = profile.reviewNote ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts index 96c02d846..83beb441f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts @@ -1,9 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsIn } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; import { ProfileStatus } from "../entities/company-profile.entity"; export class UpdateCompanyProfileStatusDto { @ApiProperty({ enum: ProfileStatus }) @IsIn(Object.values(ProfileStatus)) status!: ProfileStatus; + + /** Reviewer note — required in practice when rejecting so the customer knows why. */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts new file mode 100644 index 000000000..cec670787 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -0,0 +1,87 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +/** + * Lifecycle of a customer's proposed profile change. Edits made on the portal + * settings page by an already-approved company are staged here (not written to + * the live Company row) until a backoffice reviewer approves — at which point + * the snapshot is applied — or rejects with a note, after which the customer can + * amend and resubmit. + */ +export enum ChangeRequestStatus { + Pending = "pending", + Approved = "approved", + Rejected = "rejected", +} + +/** + * A single staged business-license change on one company profile, awaiting + * review. `add` → a new file was uploaded under the pending code and becomes + * live on approval; `remove` → an existing live file is deleted on approval. + * A "replace" is recorded as a `remove` of the old file plus an `add` of the + * new one. `fileId` is the FileRecord id the op targets. + */ +export interface LicenseChangeIntent { + profileId: string; + op: "add" | "remove"; + fileId: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + +/** File references staged alongside a change request (documents/licenses). */ +export interface ChangeRequestDocuments { + /** FileRecord ids uploaded against the company while this request was open. */ + documentFileIds?: string[]; + /** Staged per-profile business-license add/remove intents. */ + licenseChanges?: LicenseChangeIntent[]; +} + +@Entity({ schema: "freight", name: "company_change_request" }) +@Index(["companyId"]) +@Index(["status"]) +export class CompanyChangeRequest extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** + * Proposed profile field values, shaped as `Partial`. Covers + * the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/ + * PoA fields land in `Company.attributes` on approval). + */ + @Column({ name: "snapshot", type: "jsonb" }) + snapshot!: Record; + + /** Staged document/license file references (see {@link ChangeRequestDocuments}). */ + @Column({ name: "documents", type: "jsonb", nullable: true }) + documents?: ChangeRequestDocuments | null; + + @Column({ + name: "status", + type: "varchar", + length: 20, + default: ChangeRequestStatus.Pending, + }) + status!: ChangeRequestStatus; + + /** Backoffice reviewer's rejection note. */ + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; + + @Column({ name: "submitted_by", type: "uuid", nullable: true }) + submittedBy?: string | null; + + @Column({ name: "submitted_at", type: "timestamptz", nullable: true }) + submittedAt?: Date | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index e61668a07..72696766f 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -13,11 +13,17 @@ export enum ProfileType { export enum ProfileStatus { Active = "active", Pending = "pending", + /** Reviewer declined the role; carries a note. Customer can reapply → Pending. */ + Rejected = "rejected", Suspended = "suspended", Blacklisted = "blacklisted", } -/** A business-license document stored directly on the company profile. */ +/** + * @deprecated Legacy inline shape. Business-license files now live in the + * FileRecord model (`freight.files`, resource `company_profiles`). Kept only for + * the by-reference reuse shape consumed by bookings/contracts snapshots. + */ export interface BusinessLicenseFile { name: string; url: string; @@ -25,6 +31,19 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** A business-license file plus its change-review state, surfaced to clients. */ +export interface ProfileLicenseFileView { + id: string; + name: string; + size: number; + mimeType: string; + /** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ + status: "live" | "pending_add" | "pending_remove"; +} + @Entity({ schema: "freight", name: "company_profiles" }) @Index(["reference"], { unique: true }) @Index(["type"]) @@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; + + /** Reviewer's note when the role is Rejected (cleared on reapply). */ + @Column({ name: "review_note", type: "text", nullable: true }) + reviewNote?: string | null; + + @Column({ name: "reviewed_by", type: "uuid", nullable: true }) + reviewedBy?: string | null; + + @Column({ name: "reviewed_at", type: "timestamptz", nullable: true }) + reviewedAt?: Date | null; } 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/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index aaf064bff..095857959 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -326,10 +326,20 @@ export class ContractsService { companyProfileId: string | null, ): Promise { if (!companyProfileId) return; - const profile = await this.dataSource - .getRepository(CompanyProfile) - .findOne({ where: { id: companyProfileId } }); - const docs = profile?.businessLicenseFiles ?? []; + // Business-license files are FileRecords (resource "company_profiles"); carry + // the live ones by reference. Staged/pending uploads are excluded by code. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const docs = records + .filter((r) => r.code === 'business_license') + .map((r) => ({ + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + })); if (docs.length === 0) return; const slug = (name: string) => 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/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 4966d7ff9..3fd0752f7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -124,6 +124,15 @@ export class FilesService { await this.filesRepository.softDelete(id); } + /** + * Re-slot a stored file under a new `code` (e.g. promote a staged + * `business_license_pending` file to the live `business_license` code once a + * change request is approved). Bytes and URL are untouched. + */ + async setCode(id: string, code: string): Promise { + await this.filesRepository.update(id, { code }); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts new file mode 100644 index 000000000..9d7f32c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -0,0 +1,37 @@ +import { DataSource } from 'typeorm'; + +import { NotificationsService } from './notifications.service'; + +/** + * Best-effort SMS + email fan-out to a company's contacts. Looks up the + * company's phone/email and sends the message over both channels, swallowing + * per-channel failures so a missing provider never breaks the caller's flow. + */ +export async function sendCompanyChannels( + dataSource: DataSource, + notifications: NotificationsService, + companyId: string, + message: string, +): Promise { + const [contact]: Array<{ phone: string | null; email: string | null }> = + await dataSource.query( + `SELECT COALESCE(phone, etrade_phone) AS phone, email + FROM freight.companies + WHERE id = $1 AND deleted_at IS NULL`, + [companyId], + ); + if (contact?.phone) { + try { + await notifications.directSend('sms', contact.phone, message); + } catch { + /* best-effort: SMS provider unavailable */ + } + } + if (contact?.email) { + try { + await notifications.directSend('email', contact.email, message); + } catch { + /* best-effort: email provider unavailable */ + } + } +} 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..26f8749be 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 = @@ -171,6 +177,8 @@ export interface BatchBoardSchedule { /** Weight committed on the train (allocated + selected-for-batch). */ usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; @@ -284,6 +292,9 @@ export class BookingBatchService implements OnModuleInit { * schedule-scoped — only the fill is day-level). */ async processRouteDay(group: RouteDayGroup): Promise { + this.logger.log( + `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); const scheduleIds = await this.fillRouteDay( group.originYardId, group.destinationYardId, @@ -459,18 +470,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 +500,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 +508,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 +647,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 +737,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, @@ -848,7 +868,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -887,6 +907,7 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, + maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( @@ -903,6 +924,7 @@ export class BookingBatchService implements OnModuleInit { Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + maxWagons: maxWagons ?? null, }; } @@ -941,7 +963,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1009,8 +1031,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; } @@ -1018,6 +1040,16 @@ export class BookingBatchService implements OnModuleInit { const pool = await this.bookingsRepository.findBatchPool(scheduleId); const units = this.groupConsolidatedPool(pool); let armed = false; + let reservedThisPass = 0; + + // 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; @@ -1026,34 +1058,71 @@ 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 } } - if (isGov) { - await this.allocate(scheduleId, booking, "gov"); - if (partner) await this.allocate(scheduleId, partner, "gov"); - } else { - await this.reserve(booking, scheduleId); - if (partner) await this.reserve(partner, scheduleId); - armed = true; + // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup) + // can't abort the whole top-up pass and leave the rest to trickle in one + // per tick. Log + skip the failing unit, keep going. + try { + if (isGov) { + await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); + } else { + await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); + armed = true; + } + budget.subtract(need, leg); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` + + `— skipping this unit, continuing: ${(err as Error).message}`, + ); + continue; } - budget = this.subtract(budget, need); - if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board + if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); + this.logger.log( + `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1106,8 +1175,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 +1189,32 @@ 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}`, + ); + let reservedThisPass = 0; + for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; @@ -1146,20 +1223,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,52 +1266,51 @@ 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); continue; } - if (isGov) { - await this.allocate(target.id, booking, "gov"); - if (partner) await this.allocate(target.id, partner, "gov"); - } else { - await this.reserve(booking, target.id); - if (partner) await this.reserve(partner, target.id); - target.armed = true; + // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort + // the whole pass — otherwise only the bookings before the failure get a pay + // window and the rest trickle in one-per-tick on later retries (the + // "selected one at a time / staggered" symptom). Isolate each unit: log + + // skip a failing one, keep reserving the others. The skipped unit stays in + // the pool and is retried next cycle. + try { + if (isGov) { + await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); + } else { + await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); + target.armed = true; + } + target.budget.subtract(need, legOn(target)!); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` + + `— skipping this unit, continuing the batch: ${(err as Error).message}`, + ); } - target.budget = this.subtract(target.budget, need); } + this.logger.log( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + 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 +1318,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 @@ -1295,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit { const byId = new Map(reserved.map((b) => [b.id, b])); const done = new Set(); let anySettled = false; + this.logger.debug( + `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`, + ); const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; @@ -1341,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const anySettled = await this.settleReserved(scheduleId, false); - if (anySettled) await this.fillSchedule(scheduleId); + // A settle that allocated/expired anything frees or fills capacity → re-run the + // fill so the next waiting-list bookings get a fresh pay window (top-up). + if (anySettled) { + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + await this.fillSchedule(scheduleId); + } } // ---- settle (1h after a batch) ------------------------------------------- @@ -1414,10 +1573,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 +1620,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 +1637,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) }; } @@ -1529,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + this.logger.log( + `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + + `pay by ${deadline.toISOString()}`, + ); // Customer tracking: a wagon slot is reserved and the freight pay window is // open. Doc-trigger path — silent no-op for bookings without milestone rows. void this.completeTrackingMilestones(booking.id, [ @@ -1563,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); }); + this.logger.log( + `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, + ); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); @@ -1630,18 +1798,104 @@ export class BookingBatchService implements OnModuleInit { // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); + this.logger.log( + `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + + `wagons back to the pool for top-up`, + ); + } + + /** + * 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, + ); + if (unaccepted.length > 0) { + this.logger.log( + `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` + + `on ${group.originYardId}->${group.destinationYardId} ${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( + `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, + ); + } } /** * 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 +1909,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 +1941,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 +2051,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 +2128,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-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index f1f63802e..48985bdf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -71,6 +71,24 @@ export class BookingNotifierService { }); } + /** Train carrying the booking departed — dispatched origin → destination. */ + dispatched(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has been dispatched` + + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; + void this.notifyContact(b, msg, 'DISPATCHED'); + this.inApp(b, 'Shipment dispatched', msg); + } + + /** Train carrying the booking arrived at destination. */ + arrived(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; + void this.notifyContact(b, msg, 'ARRIVED'); + this.inApp(b, 'Shipment arrived', msg); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); 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..0d9087419 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 @@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit { now, ); } catch (err) { + // This is THE line to watch when a window freezes mid-phase: the tick + // catches a throw here per-schedule and moves on, so a schedule whose + // transition keeps throwing stays stuck in its phase forever. Log the + // phase + stack so the failing step is obvious. this.logger.error( - `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + `[WINDOW] transition FAILED for schedule ${schedule.id} ` + + `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` + + `${(err as Error).message}`, + ); + this.logger.error( + `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`, ); } } @@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit { // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( - `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + + `(cycle ${schedule.bookingCycleNo})`, ); return true; } @@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit { schedule.bookingWindowStatus = 'CLOSED'; } this.logger.log( - `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` + + `review until ${docReviewEndsAt.toISOString()}`, ); return true; } @@ -263,16 +274,22 @@ 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()}`, + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + + `until ${paymentPhaseEndsAt.toISOString()}`, ); return true; } @@ -282,6 +299,10 @@ export class BookingWindowService implements OnModuleInit { schedule.paymentPhaseEndsAt != null && now >= schedule.paymentPhaseEndsAt ) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` + + `(allocate paid / expire unpaid) then concluding the cycle`, + ); await this.bookingBatchService.settleDueReservations(schedule.id); await this.concludeCycle(schedule, cfg, now); return true; @@ -301,6 +322,9 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'FULL'); await this.setPhase(schedule, { windowPhase: 'DONE' }); await this.tryAutoFinalize(schedule.id); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, + ); return; } @@ -319,7 +343,8 @@ export class BookingWindowService implements OnModuleInit { if (nextOpensAt == null) { await this.setPhase(schedule, { windowPhase: 'DONE' }); this.logger.log( - `Schedule ${schedule.id} not full but no cycle fits before departure — window done`, + `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + + `departure — window DONE`, ); return; } @@ -342,7 +367,8 @@ export class BookingWindowService implements OnModuleInit { }); const sameDay = eatDay(nextOpensAt) === eatDay(now); this.logger.log( - `Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` + + `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.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..04a972ed7 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,10 @@ describe('TrainSchedulingService', () => { htmlToPdfBuffer: jest.fn(), } as never, { emitPhase: jest.fn() } as never, // bookingWindowGateway + { + autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), + } as never, // bookingJourneyService + { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier ); 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..754a302d3 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'; @@ -70,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -113,6 +116,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 +243,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,10 +281,39 @@ export class TrainSchedulingService { private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, + private readonly bookingJourneyService: BookingJourneyService, + private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Notify each booking's customer that their shipment was dispatched / arrived, + * with a deep-link to the booking. Fire-and-forget — never blocks the action. + */ + private async notifyScheduleBookings( + schedule: TrainSchedule, + event: 'dispatched' | 'arrived', + ): Promise { + try { + const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean); + if (!ids.length) return; + const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null; + const destination = + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null; + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(ids) }, + relations: { company: true }, + }); + for (const b of bookings) { + if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); + else this.bookingNotifier.arrived(b, origin, destination); + } + } catch (err) { + this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); + } + } + /** * Complete customer-tracking clearance milestones for every booking on a * schedule when a physical lifecycle event fires (dispatch, arrive, load, @@ -290,15 +324,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 +851,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 +1506,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,19 +1555,26 @@ 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 }, + ); } + void this.notifyScheduleBookings(schedule, 'dispatched'); return this.getTrainScheduleById(scheduleId); } @@ -2426,18 +2500,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 +2522,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,12 +2570,17 @@ 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 }, + ); } + void this.notifyScheduleBookings(schedule, 'arrived'); const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); @@ -2503,7 +2596,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 +2729,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 +2798,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 +2853,12 @@ export class TrainSchedulingService { containerWagonType, bulkWagonType, }); + this.stampSlotLegs( + wagonPlan, + fittingBookings, + dto.originStationId, + dto.destinationStationId, + ); violations.push( ...(await this.validatePhysicalFleetForPlan( @@ -3047,6 +3186,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 +3240,7 @@ export class TrainSchedulingService { sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, + boardYardId: slot.boardYardId ?? null, })), wagons, targetScheduleId, @@ -3108,7 +3249,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 +3282,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 +3462,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 +3513,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 +3822,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 +3951,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 +3982,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 +4004,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 +4034,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 +4052,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 +4073,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 +4088,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 +4247,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 +4316,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 +4450,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..a99ca4f46 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 @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -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) @@ -58,4 +58,9 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; } 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/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts new file mode 100644 index 000000000..08b15d536 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Optional explicit storage location. When warehouse/yard/zone are all provided, + * the item is stored there directly; otherwise store() falls back to the + * allocation-rule / capacity-balanced auto pick. + */ +export class StoreInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 290b6f0c2..a54f40973 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Handover — signature needed', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); + } catch (err) { + this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); + } + } list(bookingId: string): Promise { return this.dataSource.getRepository(BookingHandover).find({ @@ -22,6 +54,32 @@ export class HandoverService { }); } + /** + * Ask the customer to sign the booking's handover. Ensures a handover exists + * (creates a booking-level self-haul one if none yet), then fires the + * sign-needed notification (in-app + SMS + email). Idempotent to re-send. + */ + async requestSignature( + bookingId: string, + ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> { + const repo = this.dataSource.getRepository(BookingHandover); + const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); + + if (existing.length === 0) { + // No handover yet (truck not arrived): create a booking-level one so the + // customer has something to sign. ensureForArrivedTruck notifies on create. + const created = await this.ensureForArrivedTruck(bookingId, {}); + return { notified: true, reference: created.reference, alreadySigned: false }; + } + + const unsigned = existing.find((h) => !h.signedAt); + if (!unsigned) { + return { notified: false, reference: existing[0].reference, alreadySigned: true }; + } + await this.notifySignNeeded(bookingId, unsigned.reference); + return { notified: true, reference: unsigned.reference, alreadySigned: false }; + } + /** * Self-haul: ensure a handover exists for a customer truck that just arrived. * Idempotent — one per (booking, truck). Runs inside the caller's transaction @@ -54,6 +112,7 @@ export class HandoverService { }), ); this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + void this.notifySignNeeded(bookingId, reference); return saved; } 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..b5894c21b 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 @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; + import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { + private readonly logger = new Logger(WarehouseInspectionService.name); + constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -44,7 +53,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, @@ -83,8 +92,10 @@ export class WarehouseInspectionService { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", + b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -105,6 +116,36 @@ export class WarehouseInspectionService { if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); + } else if (!hasLastMile && !row.customerTruckAssignedAt) { + // Self-haul import: goods are pickup-ready but no collection truck is + // assigned yet — nudge the customer to assign one from the portal. + void this.notifyTruckAssignmentNeeded(row); + } + } + + /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ + private async notifyTruckAssignmentNeeded(row: { + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + }): Promise { + if (!row.companyId || !row.bookingId) return; + const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, + ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 84baaf4da..9eb4ea502 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; @@ -267,9 +268,9 @@ export class WarehouseInventoryController { } @Post(':id/store') - @ApiOperation({ summary: 'Mark received inventory as STORED' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.store(id, performedBy); + @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { + return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') @@ -354,12 +355,24 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('bookings/:bookingId/request-handover-signature') + @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) + requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.requestSignature(bookingId); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } + @Get('bookings/:bookingId/container-weights') + @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) + containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingContainerWeights(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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..14f4e27f3 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 @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; +import { NotificationAudience, NotificationType } from '@edr/types'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -356,12 +359,14 @@ export interface ImportUnloadedRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + hasAssignedTruck: boolean; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; handoverDocumentReference: string | null; handoverDocumentDate: string | null; deliveredAt: string | null; + notes: string | null; } @Injectable() @@ -383,8 +388,41 @@ export class WarehouseInventoryService { private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, private readonly handover: HandoverService, + private readonly inbox: NotificationInboxService, ) {} + /** + * When a self-haul booking (no EDR first/last mile) is received to the warehouse + * but has no customer truck assigned yet, nudge the customer to assign one — with + * a deep-link to the booking's truck-assignment card. Fire-and-forget. + */ + private async notifyTruckAssignmentNeeded(booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }, bookingId: string): Promise { + if (!booking.companyId) return; + if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck + if (booking.customerTruckAssignedAt) return; // already assigned + const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Batch 6 — final terminal release / gate clearance. * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch @@ -510,7 +548,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 { @@ -866,7 +904,10 @@ export class WarehouseInventoryService { b.customer_truck_driver_name AS "customerTruckDriverName", b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt" + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -1002,6 +1043,7 @@ export class WarehouseInventoryService { result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); @@ -1301,12 +1343,18 @@ export class WarehouseInventoryService { b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", + (b.customer_truck_assigned_at IS NOT NULL + OR EXISTS (SELECT 1 FROM freight.last_mile lm + WHERE lm.booking_id = b.id + AND lm.vehicle_id IS NOT NULL + AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", + inv.notes AS "notes", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -2020,7 +2068,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, @@ -2104,13 +2152,30 @@ export class WarehouseInventoryService { // ── Lifecycle transitions ──────────────────────────────────────────────── - async store(id: string, performedBy?: string): Promise { + async store( + id: string, + performedBy?: string, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); + // Explicit location wins when the operator picked warehouse + yard + zone; + // otherwise fall back to the allocation-rule / capacity-balanced auto pick. + const manualLocation = + chosen?.warehouseId && chosen?.yardId && chosen?.zoneId + ? { + warehouseId: chosen.warehouseId, + yardId: chosen.yardId, + zoneId: chosen.zoneId, + path: undefined as string | undefined, + } + : null; + const criteria = await this.getInventoryAllocationCriteria(item); - const ruleLocation = await this.allocation.resolveLocation(criteria); - const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria); + const location = + manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); if (!location) { throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); @@ -2154,18 +2219,19 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } + const storedReason = manualLocation + ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` + : ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', storedAt: new Date(), warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, - notes: this.appendNote( - locked.notes, - ruleLocation?.rule - ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` - : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, - ), + notes: this.appendNote(locked.notes, storedReason), }); await this.activityLog.record( @@ -2173,9 +2239,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_STORED', inventoryId: id, warehouseId: location.warehouseId, - description: ruleLocation?.rule - ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` - : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + description: storedReason.replace(/^Stored/, 'Inventory stored'), performedBy, }, manager, @@ -2294,6 +2358,26 @@ export class WarehouseInventoryService { 'Customer must sign the handover before the exit paper can be generated', ); } + + // Authoritative weight match: the truck's net (gross − tare) must equal the + // total VGM cargo weight of the containers selected as loaded on it. + if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + const selected = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0); + const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3)); + if (expected > 0 && Math.abs(computedNet - expected) > 0.001) { + throw new BadRequestException( + `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`, + ); + } + } + } } } const releaseDate = isTruckLeaving @@ -2528,6 +2612,7 @@ export class WarehouseInventoryService { bookingReference: string | null; contractId: string | null; hasLastMile: boolean; + handoverSigned: boolean; }> > { const rows: Array<{ @@ -2574,6 +2659,10 @@ export class WarehouseInventoryService { [bookingId], ); + // Booking-level gate: the per-truck exit paper is blocked until the handover + // is fully signed, so the UI can disable "Exit Paper" with a clear reason. + const handoverSigned = await this.handover.isFullySigned(bookingId); + return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, @@ -2596,6 +2685,32 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + handoverSigned, + })); + } + + /** + * The booking's containers with their VGM cargo weight (tonnes), keyed by + * container number. Drives the truck-leaving exit weighing: the selected + * containers' total cargo weight must match (gross − tare). + */ + async bookingContainerWeights( + bookingId: string, + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string }> = + await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(bcu.vgm_tons, 0) AS "weightTons" + 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 bcu.deleted_at IS NULL + ORDER BY bcu.container_number`, + [bookingId], + ); + return rows.map((r) => ({ + containerNumber: r.containerNumber, + weightTons: Number(r.weightTons) || 0, })); } @@ -2677,7 +2792,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 +3723,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 +3846,7 @@ export class WarehouseInventoryService { `${(data.truckPlateNumber && data.truckWeightKg ? data.truckWeightKg : data.weight - ).toLocaleString()} kg`, + ).toLocaleString()} t`, ], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -3894,8 +4009,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 +4083,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 +4418,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 +4472,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 +4535,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 +4545,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/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 9ee1dc398..2508e373a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -6,9 +6,11 @@ import { NotFoundException, } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; -import { Freight } from "@edr/types"; +import { Freight, NotificationAudience, NotificationType } from "@edr/types"; import { DataSource } from "typeorm"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + import { BillingService, InvoiceEventPayload, @@ -135,6 +137,7 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, ) { } // ── Generation ─────────────────────────────────────────────────────────── @@ -968,6 +971,25 @@ export class WarehouseInvoiceService { message, `warehouse fee invoice ${invoice.invoiceNumber}`, ); + + // In-app deep-link to pay the fee from the booking. + if (invoice.customerId && invoice.bookingId) { + try { + await this.inbox.notify({ + recipients: { companyId: invoice.customerId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: "Warehouse fee due", + body: + `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`, + link: `/bookings/${invoice.bookingId}`, + data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber }, + }); + } catch (err) { + this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`); + } + } } private async notifyWarehouseFeePayment( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 5f0ce5749..4011bc14f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; @@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + NotificationInboxModule, SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index f92401dfc..140d9f6b4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -64,8 +64,8 @@ export class WarehousesService { currentWeight: 0, currentContainers: 0, currentVolume: 0, - status: 'ACTIVE', - isActive: true, + status: dto.status ?? 'ACTIVE', + isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE', }); } catch (error) { this.mapDbError(error); 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-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index aa3f2c9bb..6b6b5f198 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -12,6 +12,7 @@ import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-ru import { Route } from "../modules/routes/entities/route.entity"; import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { deriveTradeDirection } from "../common/derive-trade-direction.util"; const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; @@ -295,6 +296,7 @@ export class PricingDataSeeder { originYardId: addis.id, destinationYardId: direDawa.id, status: 'AVAILABLE', + direction: deriveTradeDirection(addis, direDawa), }), ); await milestoneRepo.save([ diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index cbbdd289f..a5e34a35d 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1,2 +1,6 @@ VITE_API_URL=http://localhost:3001 VITE_BASE_API_URL=http://localhost:3001 + +# Proactive token refresh cadence (minutes). Must stay well under the 60-min +# server session window. Default: 10. +VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 66834c9f5..8266d96d7 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -16,6 +16,10 @@ import { setCookie, } from "./cookies"; import { applyTokens } from "./http"; +import { + startTokenRefreshScheduler, + stopTokenRefreshScheduler, +} from "./refreshScheduler"; import type { AuthTokens, AuthUser } from "./types"; interface LoginPayload { @@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { void bootstrap(); }, []); + // Keep the server session alive while a user is logged in. Runs after + // login, MFA verification, and page-reload bootstrap alike. + useEffect(() => { + if (!user) { + stopTokenRefreshScheduler(); + return; + } + + startTokenRefreshScheduler(); + return stopTokenRefreshScheduler; + }, [user]); + const value = useMemo( () => ({ user, diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 5aa78e2d3..2b0cf1021 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; +/** + * Single-flight token refresh: concurrent callers (the 401 interceptor and + * the proactive scheduler) share one in-flight request so the refresh token + * is only rotated once. Throws if no refresh token is stored or the server + * rejects it — callers decide how to end the session. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + throw new Error("missing refresh token"); + } + + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); @@ -65,8 +89,7 @@ api.interceptors.response.use( return Promise.reject(error); } - const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); - if (!refreshToken) { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } @@ -74,15 +97,7 @@ api.interceptors.response.use( originalRequest._retry = true; try { - refreshPromise ??= api - .post("/auth/refresh-token", { refreshToken }) - .then((response) => response.data) - .finally(() => { - refreshPromise = null; - }); - - const tokens = await refreshPromise; - applyTokens(tokens); + const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, @@ -97,4 +112,4 @@ api.interceptors.response.use( }, ); -export { api, applyTokens }; +export { api, applyTokens, refreshSessionTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts new file mode 100644 index 000000000..1d2c14db2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts @@ -0,0 +1,82 @@ +import { isAxiosError } from "axios"; + +import { + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, +} from "./cookies"; +import { refreshSessionTokens } from "./http"; + +/** + * Proactively refreshes the token pair on a fixed cadence so the server-side + * session (a sliding 1-hour window, extended only by /auth/refresh-token) is + * kept alive while the app is open. The 401 interceptor in http.ts remains + * the reactive fallback; both share the same single-flight refresh call. + * + * The interval MUST stay well under the server session window (60 min). + */ +const DEFAULT_INTERVAL_MINUTES = 10; + +const getIntervalMs = () => { + const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES); + return ( + (Number.isFinite(minutes) && minutes > 0 + ? minutes + : DEFAULT_INTERVAL_MINUTES) * 60_000 + ); +}; + +let timerId: number | null = null; +let lastRefreshAt = 0; + +const refreshNow = async () => { + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + // Logged out elsewhere; nothing to keep alive. + stopTokenRefreshScheduler(); + return; + } + + try { + await refreshSessionTokens(); + lastRefreshAt = Date.now(); + } catch (error) { + // Network hiccups are retried on the next tick; only an explicit server + // rejection means the session is dead. + if (isAxiosError(error) && error.response) { + stopTokenRefreshScheduler(); + clearSessionCookies(); + window.location.replace("/auth"); + } + } +}; + +/** + * Browsers freeze timers in background tabs — a tab waking up past its + * refresh deadline refreshes immediately instead of waiting a full interval. + */ +const onVisibilityChange = () => { + if (document.visibilityState !== "visible") return; + if (Date.now() - lastRefreshAt >= getIntervalMs()) { + void refreshNow(); + } +}; + +export const startTokenRefreshScheduler = () => { + stopTokenRefreshScheduler(); + + // Token age is unknown here (fresh login vs. hours-old page reload), so + // refresh right away to extend the session window from "now". + lastRefreshAt = 0; + void refreshNow(); + + timerId = window.setInterval(() => void refreshNow(), getIntervalMs()); + document.addEventListener("visibilitychange", onVisibilityChange); +}; + +export const stopTokenRefreshScheduler = () => { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + document.removeEventListener("visibilitychange", onVisibilityChange); +}; 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). + + + + + )} + + {history.length > 0 && ( + + + + Review history + + {history.map((r: CompanyChangeRequest) => ( + + + {r.status} + + + + {formatDate(r.reviewedAt ?? r.updatedAt)} + + {r.note && ( + + Note: {r.note} + + )} + + + ))} + + + )} + + setRejectId(null)} + title="Reject changes" + centered + radius="lg" + > + + }> + The customer will see this note and can amend and resubmit. + +