diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 9edf388b9..a0a119861 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -23,6 +23,7 @@ "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", + "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", @@ -32,7 +33,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -84,13 +86,15 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..a81a539ba 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -69,6 +70,8 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; +import { FuelModule } from './modules/fuel/fuel.module'; +import { MaintenanceModule } from './modules/maintenance/maintenance.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -133,6 +136,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, @@ -156,6 +161,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -174,6 +180,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -195,6 +202,7 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); + await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..47d8f5b3c 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -116,8 +116,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => { freightMigrationsGlob, ], migrationsRun: true, + migrationsTransactionMode: "each", // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index caf161614..05061ed9a 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder { serviceType: this.valueOrDash( contract.serviceType?.serviceName ?? contract.serviceType?.code, ), - scheduledDate: this.formatDate(contract.estimatedShipmentDate), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index fa6087faa..1cad0fe66 100644 --- a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration { name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'free_days', type: 'int', default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tiers', type: 'jsonb', default: "'[]'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'is_active', type: 'boolean', default: true }, { name: 'created_at', type: 'timestamptz', default: 'now()' }, diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 65a3e764b..4c2fb5d95 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(), + ADD COLUMN IF NOT EXISTS invoice_number varchar(64), + ADD COLUMN IF NOT EXISTS company_id uuid, + ADD COLUMN IF NOT EXISTS company_profile_id uuid, + ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB', + ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS source varchar(255), + ADD COLUMN IF NOT EXISTS source_id varchar(255), + ADD COLUMN IF NOT EXISTS type varchar(255), + ADD COLUMN IF NOT EXISTS issued_at timestamptz, + ADD COLUMN IF NOT EXISTS payment_id uuid, + ADD COLUMN IF NOT EXISTS due_at timestamptz, + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS deleted_at timestamptz; + `, + ); + await queryRunner.query(` + UPDATE freight.invoices + SET due_at = COALESCE(due_at, issued_at, created_at, now()) + WHERE due_at IS NULL; + `); + await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE contype = 'p' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_invoices_invoice_number' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company + FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company_profile' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile + FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_payment' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment + FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`, ); await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( + CREATE TABLE IF NOT EXISTS freight.invoice_lines ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_id uuid NOT NULL, charge_type varchar NOT NULL, @@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, ); } diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts new file mode 100644 index 000000000..cc9437eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous + * or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item + * count for PER_ITEM). These two columns hold that amount on the booking; they + * stay 0 for container freight (which tracks it per line on booking_container) + * and for bulk cargo with no hazardous/reefer portion. The existing + * is_hazardous / is_reefer booleans remain the surcharge trigger. + */ +export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface { + name = 'AddBulkHazmatReeferQuantity1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts index dd246cb7d..75082c5c4 100644 --- a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -19,6 +19,31 @@ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterf name = 'CentralizeWarehouseInvoices1829000000000'; public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'booking_id' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'amount' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; + END IF; + END $$; + `); + // 1. Invoice headers. Keep the same id so items still link, and so any // external reference to the invoice id stays valid. await queryRunner.query(` diff --git a/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts new file mode 100644 index 000000000..75288e94c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface { + name = 'PhasedClearanceCycleMeta1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts new file mode 100644 index 000000000..7165ea7a8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Admin-configurable minimum days between today and export RO vessel departure. */ +export class SeedRoVesselMinDays1829000000001 implements MigrationInterface { + name = 'SeedRoVesselMinDays1829000000001'; + private readonly code = 'ro_vessel_min_days'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '2', label: '2 days' }, + { value: '3', label: '3 days' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'RO vessel minimum lead time (days)', + 'Minimum days between today and the vessel departure date on an export Release Order.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts new file mode 100644 index 000000000..c108b7baa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingClearanceMeta1829000000002 implements MigrationInterface { + name = 'BookingClearanceMeta1829000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) — driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts new file mode 100644 index 000000000..5bdfe1f30 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface { + name = 'DropCargoTypeShowFreeTextBox1830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS show_free_text_box + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts new file mode 100644 index 000000000..701b52c18 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface { + name = 'RouteStatusAndSegmentKm1830000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE' + `); + + await queryRunner.query(` + UPDATE freight.routes + SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_name" + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS name + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status) + `); + + await queryRunner.query(` + ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS name varchar(120) + `); + await queryRunner.query(` + UPDATE freight.routes SET name = id::text WHERE name IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + UPDATE freight.routes + SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END + `); + + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS status + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_status" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts new file mode 100644 index 000000000..df57cd828 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface { + name = 'PreClearanceFinalizedAt1830000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts new file mode 100644 index 000000000..c6da11cc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface { + name = 'AddWarehouseFeeRuleTiers1831000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + DROP COLUMN IF EXISTS tiers; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts new file mode 100644 index 000000000..c137ca264 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignmentToBookings1832000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customer_truck_arrived_at, + DROP COLUMN IF EXISTS customer_truck_assigned_at, + DROP COLUMN IF EXISTS customer_truck_container_number, + DROP COLUMN IF EXISTS customer_truck_type, + DROP COLUMN IF EXISTS customer_truck_driver_name, + DROP COLUMN IF EXISTS customer_truck_plate_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e954b7e1b..7da5e2d66 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @@ -7,6 +8,7 @@ import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") @FreightAdmin() +@ApiBearerAuth() export class BillingController { constructor(private readonly billingService: BillingService) { } @@ -21,4 +23,26 @@ export class BillingController { findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); + } +} + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index dc78cd6e9..771156fd3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; +import { PaymentController } from "./payment.controller"; import { BillingService } from "./billing.service"; import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; @@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module"; CompaniesModule, DocumentsModule, ], - controllers: [BillingController, PortalBillingController], + controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) 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 61597264b..4df2a0eb3 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 @@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => { }); describe("BillingService.markInvoiceAsPaid", () => { - it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, }; const mg = { findOne: jest.fn().mockResolvedValue(open), @@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => { expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + { + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + paidAt: expect.any(Date), + paidAmount: 1500, + balanceAmount: 0, + payments: [ + { + amount: 1500, + method: "GATEWAY", + reference: "pay-1", + paidAt: expect.any(String), + metadata: null, + }, + ], + }, ); expect(events.emit).toHaveBeenCalledWith( "booking.invoice.paid", @@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => { update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); + const dataSource = { + manager: mg, + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), + }; const service = new BillingService( - { manager: mg } as never, + dataSource as never, {} as never, {} as never, events as never, @@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => { expect(mg.update).not.toHaveBeenCalled(); }); + it("rejects a payment that exceeds the outstanding balance", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect( + service.recordPayment("inv-1", { amount: 1500 }), + ).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + it("rejects payment against a cancelled invoice", async () => { const { service, mg } = serviceFor( openInvoice({ status: Freight.InvoiceStatus.Cancelled }), @@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => { expect(mg.update).not.toHaveBeenCalled(); }); }); - -describe("BillingService.settlePayable", () => { - it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { - const open = { - id: "inv-1", - status: Freight.InvoiceStatus.Pending, - source: Freight.InvoiceSource.Booking, - sourceId: "booking-1", - }; - const mg = { - findOne: jest.fn().mockResolvedValue(open), - update: jest.fn().mockResolvedValue(undefined), - }; - const events = makeEvents(); - const service = new BillingService( - { manager: mg } as never, - {} as never, - {} as never, - events as never, - {} as never, // payment - {} as never, // companies - {} as never, // invoiceDocuments - ); - - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); - - expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); - expect(mg.update).toHaveBeenCalledWith( - expect.anything(), - { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, - ); - expect(events.emit).toHaveBeenCalledWith( - "booking.invoice.paid", - expect.anything(), - ); - }); - - it("is a no-op (returns null) when the source has no open invoice", async () => { - const mg = { - findOne: jest.fn().mockResolvedValue(null), - update: jest.fn().mockResolvedValue(undefined), - }; - const events = makeEvents(); - const service = new BillingService( - { manager: mg } as never, - {} as never, - {} as never, - events as never, - {} as never, // payment - {} as never, // companies - {} as never, // invoiceDocuments - ); - - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); - - expect(settled).toBeNull(); - expect(mg.update).not.toHaveBeenCalled(); - expect(events.emit).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 f1b58ad5e..958279002 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -49,7 +49,6 @@ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ - Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.PartiallyPaid, @@ -151,16 +150,22 @@ export class BillingService { /** Sealed PDF invoice for any source, rendered by the shared document service. */ async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } /** Sealed PDF receipt; available once any payment has been recorded. */ async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException("A receipt is available only after payment is recorded."); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT")); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } /** Map a global invoice (+ lines) onto the source-agnostic document model. */ @@ -177,7 +182,11 @@ export class BillingService { if (Number(invoice.taxAmount) > 0) { totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); } - totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true }); + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); @@ -193,8 +202,18 @@ export class BillingService { { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, { label: "Currency", value: invoice.currency }, - { label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null }, - { label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, ], categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ @@ -221,19 +240,33 @@ export class BillingService { } } - /** Every invoice billed to a company, newest first, with billing relations. */ - findByCompany(companyId: string): Promise { + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { return this.invoices.findAll({ - where: { companyId }, + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, relations: { company: true, companyProfile: true }, order: { createdAt: "DESC" }, }); } /** Invoices for the signed-in customer; empty when they have no company. */ - async findForUser(userId: string): Promise { + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId) : []; + return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ @@ -251,27 +284,43 @@ export class BillingService { /** * Initiate gateway payment for one of the customer's own invoices. Verifies - * ownership, then charges whichever open invoice the source currently has - * (see {@link payInvoice}). + * ownership, then charges the invoice directly by ID (see {@link payInvoice}). */ async payInvoiceForUser( id: string, userId: string, opts: PayInvoiceOptions = {}, ): Promise { - const invoice = await this.findByIdForUser(id, userId); - return this.payInvoice( - invoice.source as Freight.InvoiceSource, - invoice.sourceId, - opts, - ); + await this.findByIdForUser(id, userId); + return this.payInvoice(id, opts); + } + + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); } // ── Generation ─────────────────────────────────────────────────────────────── /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ private nextInvoiceNumber(mg: EntityManager): Promise { - return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" }); + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); } /** @@ -289,6 +338,7 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { + console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } @@ -319,8 +369,7 @@ export class BillingService { input.subtotalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); const taxAmount = input.taxAmount ?? 0; - const totalAmount = - input.totalAmount ?? round2(subtotalAmount + taxAmount); + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? @@ -368,23 +417,89 @@ export class BillingService { // ── State transitions ──────────────────────────────────────────────────────── /** - * Mark an invoice paid and link the gateway payment, then emit - * `${source}.invoice.paid`. Full-payment only — no partial settlement. - * No-op when the invoice is already paid. Pass `manager` to enlist in a - * caller's transaction. + * Run `fn` inside a transaction and only emit its returned domain event + * after commit. When the caller passes their own `manager`, they own commit + * timing — `fn`'s event fires inline as soon as it resolves (the outer + * transaction may still roll back afterwards; this is the caller's + * documented tradeoff). When no `manager` is given, this opens its own + * transaction and defers the emit until after that transaction commits, so + * listeners (e.g. booking advancement) can never observe an invoice change + * that then rolls back. + */ + private async runTransition( + manager: EntityManager | undefined, + fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, + ): Promise { + if (manager) { + const { result, emit } = await fn(manager); + emit?.(); + return result; + } + let pending: (() => void) | undefined; + const result = await this.dataSource.transaction(async (mg) => { + const out = await fn(mg); + pending = out.emit; + return out.result; + }); + pending?.(); + return result; + } + + /** + * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, + * append the settlement to the `payments` ledger, link the gateway payment, + * then emit `${source}.invoice.paid`. Full-payment only — no partial + * settlement. No-op when the invoice is already paid. Pass `manager` to + * enlist in a caller's transaction; otherwise locks the row for update and + * emits only after commit (see {@link runTransition}). */ async markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, manager?: EntityManager, + settlement: { providerTxnId?: string; paidAt?: Date } = {}, ): Promise { - return this.transition( - invoiceId, - Freight.InvoiceStatus.Paid, - "paid", - { paymentId: paymentId ?? undefined }, - manager, - ); + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } + + const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); + const settledAmount = round2( + Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), + ); + const entry: InvoicePayment = { + amount: settledAmount, + method: "GATEWAY", + reference: settlement.providerTxnId ?? paymentId ?? null, + paidAt: paidAt.toISOString(), + metadata: null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + status: Freight.InvoiceStatus.Paid, + paymentId, + paidAt, + paidAmount: invoice.totalAmount, + balanceAmount: 0, + payments, + }; + await mg.update(Invoice, { id: invoiceId }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent("paid", updated), + }; + }); } /** @@ -396,9 +511,11 @@ export class BillingService { * at the warehouse counter); gateway settlement goes through * {@link markInvoiceAsPaid}. * - * Throws when the invoice is missing, cancelled, refunded, already fully paid, - * or when `amount` is not positive. Pass `manager` to enlist in a caller's - * transaction. + * Throws when the invoice is missing, cancelled, refunded, already fully + * paid, `amount` is not positive, or `amount` exceeds the outstanding + * balance. Pass `manager` to enlist in a caller's transaction; otherwise + * locks the row for update and emits only after commit (see + * {@link runTransition}). */ async recordPayment( invoiceId: string, @@ -406,69 +523,76 @@ export class BillingService { manager?: EntityManager, ): Promise { if (!(input.amount > 0)) { - throw new BadRequestException("Payment amount must be greater than zero."); + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); } - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); - if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.status === Freight.InvoiceStatus.Cancelled) { - throw new BadRequestException("Cannot pay a cancelled invoice."); - } - if (invoice.status === Freight.InvoiceStatus.Refunded) { - throw new BadRequestException("Cannot pay a refunded invoice."); - } - if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException("Invoice is already fully paid."); - } + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + if (round2(input.amount) > Number(invoice.balanceAmount)) { + throw new BadRequestException( + `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, + ); + } - const at = input.paidAt ?? new Date(); - const { paidAmount, balanceAmount, fullyPaid } = applySettlement( - invoice.totalAmount, - invoice.paidAmount, - input.amount, - ); - const status = fullyPaid - ? Freight.InvoiceStatus.Paid - : Freight.InvoiceStatus.PartiallyPaid; + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; - const entry: InvoicePayment = { - amount: round2(input.amount), - method: input.method ?? null, - reference: input.reference ?? null, - paidAt: at.toISOString(), - metadata: input.metadata ?? null, - }; - const payments = [...(invoice.payments ?? []), entry]; + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; - await mg.update( - Invoice, - { id: invoice.id }, - { + const patch = { paidAmount, balanceAmount, status, payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, - } as never, - ); + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + }; + await mg.update(Invoice, { id: invoice.id }, patch as never); - const updated = { - ...invoice, - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : invoice.paidAt ?? null, - } as Invoice; - - if (fullyPaid) this.emitInvoiceEvent("paid", updated); - return updated; + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: fullyPaid + ? () => this.emitInvoiceEvent("paid", updated) + : undefined, + }; + }); } /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. - * No-op when already refunded. + * No-op when already refunded. Throws when the invoice has no recorded + * payment (nothing to refund). */ async markInvoiceAsRefunded( invoiceId: string, @@ -480,12 +604,20 @@ export class BillingService { "refunded", {}, manager, + (invoice) => { + if (!(Number(invoice.paidAmount) > 0)) { + throw new BadRequestException( + "Cannot refund an invoice with no recorded payment.", + ); + } + }, ); } /** * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. - * No-op when already cancelled. + * No-op when already cancelled. Throws when the invoice has payments + * recorded against it (refund it instead). */ async cancelInvoice( invoiceId: string, @@ -497,16 +629,23 @@ export class BillingService { "cancelled", {}, manager, + (invoice) => { + if (Number(invoice.paidAmount) > 0) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + }, ); } /** * Load the invoice, apply the new status (+ extra columns), then emit - * `${source}.invoice.`. No-op (returns the invoice) when it is already - * in the target status. Throws when the invoice does not exist. - * - * Note: the event fires in-process synchronously. When a `manager` from an - * outer transaction is passed, listeners run before that transaction commits. + * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) + * when it is already in the target status. Throws when the invoice does not + * exist or `guard` rejects the current state. Pass `manager` to enlist in a + * caller's transaction; otherwise locks the row for update and emits only + * after commit (see {@link runTransition}). */ private async transition( invoiceId: string, @@ -514,17 +653,27 @@ export class BillingService { event: string, extra: { paymentId?: string }, manager?: EntityManager, + guard?: (invoice: Invoice) => void, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); - if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.status === status) return invoice; + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === status) return { result: invoice }; + guard?.(invoice); - await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); - const updated = { ...invoice, ...extra, status } as Invoice; - this.emitInvoiceEvent(event, updated); - return updated; + const updated = { ...invoice, ...extra, status } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent(event, updated), + }; + }); } /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ @@ -548,16 +697,17 @@ export class BillingService { // ── Payment reconciliation (by source) ─────────────────────────────────────── /** - * The invoice a gateway payment should settle for a source record, or null if - * none. This is the billing document of record for "what is owed" — callers - * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than - * recomputing from the source's own total, so discounts/penalties/adjustments - * carried on the invoice are honored. + * The invoice a source record already has open, or null if it needs a new + * one. This is the idempotency check every `ensureInvoiceFor*` (booking, + * first-mile, last-mile) runs before generating — it must see DRAFT + * invoices too, not just issued ones, otherwise a source that already has + * an unissued draft gets a second, duplicate invoice minted alongside it + * instead of that draft being reused and then issued. * * Pass `type` to select a specific invoice when a source carries several (e.g. * a booking's up-front vs final charge); omit it to settle whichever single - * invoice is currently open. Returns the most recent matching open (unpaid, - * non-cancelled) invoice. + * invoice is currently open. Returns the most recent matching draft-or-open + * (unpaid, non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, @@ -568,7 +718,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -576,74 +726,130 @@ export class BillingService { } /** - * Settle a source's currently-open invoice as paid and link the gateway - * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then - * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial - * settlement. No-op (returns null) when the source has no open invoice. - * - * Type-blind by design: settles whichever invoice is due; any per-type reaction - * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. - * Pass the caller's transaction `manager` to enlist in its DB transaction. - * - * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` - * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper - * for callers that settle by source rather than by gateway intent id. + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. */ - async settlePayable( + findInvoice( source: Freight.InvoiceSource, sourceId: string, - paymentId: string | null, - manager?: EntityManager, + type?: string, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: In(OPEN_STATUSES) }, + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); - if (!invoice) return null; - - return this.markInvoiceAsPaid(invoice.id, paymentId, mg); } /** - * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. - * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. - * No-op (returns null) when the source has no paid invoice. + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED — a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). * - * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) - * to enlist in its DB transaction. + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. */ - async refundPayable( + async expirePayable( source: Freight.InvoiceSource, sourceId: string, + type?: string, manager?: EntityManager, ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; - return this.markInvoiceAsRefunded(invoice.id, mg); + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + mg, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + type?: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + /** + * Force an invoice to `status`, including issuing a still-DRAFT invoice + * (stamping `issuedAt`) — unlike the other transitions here, this is a + * blunt admin/workflow override, not a settlement. No-op when the invoice + * is missing or already terminal (paid/cancelled/refunded/expired). + */ + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, + }); + if (!invoice) return; + await mg.update( + Invoice, + { id: invoice.id }, + { status, issuedAt: invoice.issuedAt ?? new Date() }, + ); } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** - * Charge a source's open invoice through the payment gateway. Billing is the - * single place that turns "what is owed" (the invoice) into a payment intent — - * the domain never talks to the payment service directly. Resolves the open - * invoice, opens an intent for `invoice.totalAmount`, records the intent id on - * the invoice (the settlement correlation key), and returns the client action. + * Charge an invoice through the payment gateway. Billing is the single place + * that turns "what is owed" (the invoice) into a payment intent — the domain + * never talks to the payment service directly. Resolves the invoice by ID, + * opens an intent for `invoice.balanceAmount` (so partial payments are honored), + * records the intent id on the invoice (the settlement correlation key), and + * returns the client action. * * When the provider settles synchronously, the invoice is settled inline here — * after the intent id is stored — so the `payment.succeeded` correlation can - * never fire before the link exists. Throws when the source has no open invoice. + * never fire before the link exists. Throws when the invoice is not found or + * not in an open/payable status. */ async payInvoice( - source: Freight.InvoiceSource, - sourceId: string, + invoiceId: string, opts: { method?: string; platform?: "web" | "mobile"; @@ -652,13 +858,22 @@ export class BillingService { failureUrl?: string; } = {}, ): Promise { - const invoice = await this.findPayable(source, sourceId); + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + }); if (!invoice) { - throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + throw new NotFoundException( + `Invoice ${invoiceId} not found or not in a payable status`, + ); + } + + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); + if (!(amountDue > 0)) { + throw new BadRequestException("Invoice has no outstanding balance."); } const result = await this.payment.initiate({ - referenceId: sourceId, + referenceId: invoice.sourceId, source: invoice.source, // Freight payments settle under the generic SHIPMENT reference — how the // payment service attributes them to the freight API. The payment ↔ invoice @@ -667,7 +882,7 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, - amountMinor: Math.round(Number(invoice.totalAmount)), + amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -702,8 +917,8 @@ export class BillingService { */ async settleByPaymentId( paymentId: string, - _providerTxnId?: string, - _paidAt?: Date, + providerTxnId?: string, + paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId, status: In(OPEN_STATUSES) }, @@ -711,6 +926,9 @@ export class BillingService { }); if (!invoice) return null; - return this.markInvoiceAsPaid(invoice.id, paymentId); + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { + providerTxnId, + paidAt, + }); } } diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts index d36788600..d3e6a208f 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -11,7 +11,7 @@ /** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ export interface SqlRunner { - query(sql: string, params?: unknown[]): Promise>; + query(sql: string, params?: unknown[]): Promise; } export interface InvoiceNumberOptions { @@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber( const prefix = `${opts.code}-${ymd}-`; const column = opts.column ?? "invoice_number"; - const [row] = await runner.query( + // Serialize concurrent allocation for this exact day+code prefix so two + // simultaneous transactions can't both read the same MAX(seq) and mint a + // duplicate number. Session-scoped to the caller's transaction — released + // automatically on commit/rollback. Different prefixes hash to different + // keys and never contend with each other. + await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]); + + const rows = (await runner.query( `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq FROM ${opts.table} WHERE ${column} LIKE $1`, [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; + )) as Array<{ seq: number | string }>; + const next = Number(rows[0]?.seq ?? 0) + 1; return `${prefix}${String(next).padStart(5, "0")}`; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts similarity index 83% rename from apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts rename to apps/edr-freight-api/src/modules/billing/payment.controller.ts index ae01ebc36..543c84201 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -16,9 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { BillingService } from "../billing/billing.service"; +import { BillingService } from "./billing.service"; import { InitiatePaymentDto, InitiateResponseDto, @@ -27,25 +26,24 @@ import { } from "../payment/payments.dto"; /** - * Booking-payment entrypoints. This is the ONE place that knows a payment is for a - * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands - * off to billing, which resolves the invoice/amount and drives the gateway. Billing - * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Central payment entrypoints. Domain-agnostic — the caller supplies an + * invoice ID and the billing service resolves the amount and drives the + * gateway. The domain never talks to the payment service directly. * Routes are unchanged (`/payments/*`) so the portal is unaffected. */ @ApiTags("Payment") @Controller("payments") -export class BookingPaymentController { +export class PaymentController { constructor(private readonly billing: BillingService) { } @Post("initiate") @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: "Charges the booking's open invoice through the payment gateway.", + summary: "Initiate payment for an invoice", + description: "Charges the invoice through the payment gateway.", }) @ApiOkResponse({ type: InitiateResponseDto }) initiate(@Body() dto: InitiatePaymentDto): Promise { - return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + return this.billing.payInvoice(dto.invoiceId, { method: dto.method, platform: dto.platform, payerAccount: dto.payerAccount, @@ -59,23 +57,23 @@ export class BookingPaymentController { @ApiOperation({ summary: "Browser checkout redirect", description: - "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", }) - @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "invoiceId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) @ApiProduces("text/html") async checkout( - @Query("bookingId") bookingId: string, + @Query("invoiceId") invoiceId: string, @Query("method") method: PaymentMethodTypeEnum, @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { - if (!bookingId) { + if (!invoiceId) { return res .status(HttpStatus.BAD_REQUEST) .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + .send(this.buildErrorHtml("Missing required query parameter: invoiceId")); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { return res @@ -86,8 +84,7 @@ export class BookingPaymentController { try { const result = await this.billing.payInvoice( - Freight.InvoiceSource.Booking, - bookingId, + invoiceId, { method, platform }, ); const url = diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 5a007c320..94e917754 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -5,14 +5,18 @@ import { Param, ParseUUIDPipe, Post, + Query, + Res, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; import { PayInvoiceDto } from "./dto/pay-invoice.dto"; @@ -29,8 +33,15 @@ export class PortalBillingController { @Get("my-invoices") @ApiOperation({ summary: "List the signed-in customer's invoices" }) - findMine(@CurrentUser() user: AuthUserPayload) { - return this.billingService.findForUser(resolveAuthUserId(user)); + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); } @Get("my-invoices/:id") @@ -42,6 +53,34 @@ export class PortalBillingController { return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); } + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + @Post("my-invoices/:id/pay") @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) pay( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 47338f196..3bfb838b4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,20 +1,26 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager } from "typeorm"; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, InvoiceLineInput, -} from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { PriceLineItemDto } from './dto/generate-price-response.dto'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { BookingsRepository } from "./bookings.repository"; +import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ interface StoredPricingBreakdown { @@ -23,6 +29,12 @@ interface StoredPricingBreakdown { currency?: string; } +export interface InvoiceOptions { + dueDate?: Date; + invoiceType?: string; + invoiceStatus?: Freight.InvoiceStatus; +} + /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; @@ -52,32 +64,28 @@ export class BookingInvoiceService { * Ensure the booking has its invoice, generating one from the snapshotted * pricing breakdown if absent. Called when a booking reaches a billable state. * Idempotent — returns the existing open invoice instead of a duplicate. - * Returns `null` (and logs) when the booking is not billable: no company to - * bill (e.g. government bookings whose `companyId` is null, which the invoices - * FK requires), or no priced amount. + * Throws `BadRequestException` when the booking is not billable: no company + * to bill (e.g. government bookings whose `companyId` is null, which the + * invoices FK requires), or no priced amount. */ - async ensureInvoiceForBooking(booking: Booking): Promise { + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, - Freight.InvoiceType.Prepaid, + "PREPAID", ); if (existing) return existing; if (!booking.companyId) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, ); - return null; } - const input = this.buildInput(booking); - if (!input) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, - ); - return null; - } + const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } @@ -87,10 +95,10 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ - @OnEvent('booking.invoice.paid') + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { switch (payload.type) { - case Freight.InvoiceType.Prepaid: + case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -100,6 +108,14 @@ export class BookingInvoiceService { } } + updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + return this.billing.updateStatus(invoiceId, status, manager); + } + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -114,16 +130,18 @@ export class BookingInvoiceService { private async advanceBookingOnPayment(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { - this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); return; } - if (booking.paymentStatus === 'PAID') return; + if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, - { paymentStatus: 'PAID', status: 'PAID' }, + { paymentStatus: "PAID", status: "PAID" }, ); await this.firstMile.acceptBooking(bookingId); }); @@ -138,9 +156,13 @@ export class BookingInvoiceService { } /** Map a booking's pricing snapshot into a generic invoice request. */ - private buildInput(booking: Booking): GenerateInvoiceInput | null { - const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; - const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB'; + private buildInput( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): GenerateInvoiceInput { + const breakdown = (booking.pricingBreakdown ?? + {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ chargeType: l.code, @@ -155,10 +177,14 @@ export class BookingInvoiceService { // Fall back to a single freight line when no breakdown was snapshotted. if (lines.length === 0) { const amount = Number(booking.totalAmount); - if (!Number.isFinite(amount) || amount <= 0) return null; + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + } lines.push({ - chargeType: 'FREIGHT', - description: 'Rail freight', + chargeType: "FREIGHT", + description: "Rail freight", quantity: 1, unitRate: amount, amount, @@ -166,7 +192,9 @@ export class BookingInvoiceService { }); } - const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + const subtotal = round2( + lines.reduce((sum, l) => sum + Number(l.amount), 0), + ); let totalAmount = subtotal; // Honor a staff price override: bill the adjusted total, recording the delta @@ -176,8 +204,8 @@ export class BookingInvoiceService { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ - chargeType: 'ADJUSTMENT', - description: 'Staff price adjustment', + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, @@ -190,12 +218,14 @@ export class BookingInvoiceService { return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, - type: Freight.InvoiceType.Prepaid, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts deleted file mode 100644 index 1fbe34e1f..000000000 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Freight } from '@edr/types'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; -import { assertBookingStatus } from './booking-status.util'; -import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -import { BillingService } from '../billing/billing.service'; -import { PaymentMethodTypeEnum } from '../payment/payments.dto'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } - -@Injectable() -export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - private readonly billing: BillingService, - ) { } - - /** - * Start payment for a booking. The booking never touches the payment gateway - * directly — it charges its invoice through billing, which resolves the amount - * and drives the provider. Returns the provider redirect URL (empty when none). - */ - async pay(bookingId: string): Promise<{ redirectUrl: string }> { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); - - const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { - method: PaymentMethodTypeEnum.TELEBIRR, - platform: 'web', - }); - - const action = resp.clientAction as { type?: string; url?: string } | undefined; - return { - redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', - }; - } - - private async requireBooking(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); - if (!booking) throw new NotFoundException(`Booking ${id} not found`); - return booking; - } -} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index e15fcba0d..6e96dc6f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -58,7 +58,6 @@ export function buildCargoTypeTree( id: child.id, name: child.cargoTypeName, code: child.code, - show_free_text_box: child.showFreeTextBox, unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index a9806f1f7..f9b160672 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => { {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index ff08784a6..d62883d53 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => { fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index cc2288963..66df02ac4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => { {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, bookingBatchService }; } 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 2ebceeabc..898f78cd5 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 @@ -4,8 +4,8 @@ import { Inject, Injectable, Logger, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; @@ -15,7 +15,6 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; -import { BookingInvoiceService } from './booking-invoice.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; @@ -25,32 +24,47 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; +import { ContractDocPhase } from '@edr/types'; + + +import { Freight } from "@edr/types"; +import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() export class BookingTransitionService { private readonly logger = new Logger(BookingTransitionService.name); - constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, - private readonly invoiceService: BookingInvoiceService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, + @Inject(forwardRef(() => BookingClearanceService)) + private readonly bookingClearanceService: BookingClearanceService, + @Inject(forwardRef(() => ClearanceWorkflowService)) + private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + ) {} + private isPhasedGeneralCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -69,7 +83,8 @@ export class BookingTransitionService { totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -79,7 +94,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -109,7 +124,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -121,16 +136,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -149,9 +165,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -173,7 +190,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -183,17 +200,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -203,7 +220,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -217,14 +234,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -234,12 +251,12 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, @@ -255,17 +272,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -283,8 +300,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -296,14 +313,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -315,29 +335,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -359,90 +386,64 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CONTRACT_READY']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); return this.bookingsService.findById(updated!.id); } - async marketingApprove(bookingId: string, actorId: string): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - marketingApprovedById: actorId, - marketingApprovedAt: new Date(), - lockedAt: new Date(), - } as never); - - const executed = await this.bookingsService.findById(updated!.id); - - // Billable state reached — generate the invoice payment will settle. - // Non-blocking: a billing hiccup must not undo the execution. - await this.invoiceService - .ensureInvoiceForBooking(executed) - .catch((err) => - this.logger.error( - `Failed to generate invoice for booking ${executed.reference}: ${ - err instanceof Error ? err.message : String(err) - }`, - ), - ); - - return executed; - } - async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PAID']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + status: "IN_TRANSIT", } as never); return this.bookingsService.findById(updated!.id); } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['IN_TRANSIT']); + assertBookingStatus(booking, ["IN_TRANSIT"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); return this.bookingsService.findById(updated!.id); @@ -451,22 +452,23 @@ export class BookingTransitionService { async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); return this.bookingsService.findById(updated!.id); } @@ -479,20 +481,20 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -513,32 +515,44 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; + phase?: string | null; + milestones?: unknown[]; + nextAction?: unknown; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + return this.bookingClearanceService.getClearanceView(bookingId); + } const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -556,28 +570,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -611,13 +623,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -632,33 +646,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -669,8 +688,20 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); + + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onCustomerDocsUploadedForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } + return this.bookingsService.findById(bookingId); } @@ -694,7 +725,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -702,7 +736,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -713,22 +747,36 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); + } + if ( + status === 'QUERIED' && + this.isPhasedGeneralCustoms(booking) && + booking.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); } await this.bookingsRepository.setDocumentReviewStatus( @@ -739,15 +787,37 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } } - return this.bookingsService.findById(bookingId); + + const updated = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(updated)) { + const allApproved = await this.isClearanceFullyApproved(updated); + if (allApproved) { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + const phase = + updated.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput; + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: phase, + } as never); + } + } + + return updated; } /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ @@ -756,18 +826,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -781,19 +853,28 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + throw new BadRequestException( + 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', + ); + } assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -802,13 +883,13 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); return this.bookingsService.findById(bookingId); } @@ -827,11 +908,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -844,12 +928,12 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); @@ -865,27 +949,27 @@ export class BookingTransitionService { */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', + status: "OPERATION_CHANGES_REQUESTED", } as never); return this.bookingsService.findById(bookingId); } @@ -907,9 +991,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -917,7 +1009,7 @@ export class BookingTransitionService { } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -932,21 +1024,23 @@ export class BookingTransitionService { return this.bookingsService.findById(booking.id); } - async enrichBookingResponse(booking: Booking): Promise { + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + } + > { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", ); const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); @@ -957,4 +1051,4 @@ export class BookingTransitionService { nextStep, }; } -} \ No newline at end of file +} 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 0f413199b..688c6ee18 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -12,6 +12,7 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -19,7 +20,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -27,12 +28,17 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; @@ -54,16 +60,20 @@ import { StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { type AuthUserPayload, resolveAuthUserId, -} from '../../common/resolve-auth-user-id'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -72,12 +82,13 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, + private readonly bookingClearanceService: BookingClearanceService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -87,15 +98,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -105,16 +125,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + summary: "Update booking", + description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { @@ -122,7 +142,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -139,7 +159,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). @@ -165,27 +185,29 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @Get("list-summary") + @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } - @Get('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -194,32 +216,32 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -233,10 +255,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -254,15 +276,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/customer-truck-assignment') + @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + async assignCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CustomerTruckAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const assigned = await this.bookingsService.assignCustomerTruck(id, dto); + return this.transitionService.enrichBookingResponse(assigned); + } + + @Get(':id/customer-truck-assignment/freight-order') + @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + async customerTruckFreightOrder( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = + await this.bookingsService.customerTruckFreightOrderCopies(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/tracking') @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -276,66 +331,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -344,22 +399,37 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + getBookingEtClearanceQueue() { + return this.bookingClearanceService.etQueue(); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + getBookingDjClearanceQueue() { + return this.bookingClearanceService.djQueue(); + } + @Get(':id/clearance') @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -369,14 +439,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -386,15 +456,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + - 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (→ batch pool), " + + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -407,11 +477,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -425,13 +497,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -441,21 +513,176 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + async uploadBookingDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeclaration( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + async adviseBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + const booking = await this.bookingClearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.finalizePreClearance(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + async uploadBookingDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + const booking = await this.bookingClearanceService.uploadDutySlip(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + async uploadBookingTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadTransitPermit( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: TCurrentUser, + ) { + const result = await this.bookingClearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + return { + ...this.transitionService.enrichBookingResponse(result.booking), + hold: result.hold, + holdReason: result.holdReason, + }; + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + async requestBookingRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestRoAmendment( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + async confirmBookingExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.confirmExportRelease( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -467,14 +694,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake → set contract validity window + start approval chain', + "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -486,11 +713,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -502,11 +729,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/government-expedite') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + @ApiOperation({ + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", + }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -516,16 +745,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @Post(":id/approval-steps/:stepId/approve") @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) - @ApiOperation({ summary: 'Approve one approval step in sequence' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -539,12 +768,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -557,53 +786,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Generate contract PDF from template" }) + async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/view') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } - @Post(':id/contract/sign') - @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + @Post(":id/contract/sign") + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { @@ -615,28 +844,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/contract/signatures") + @ApiOperation({ summary: "List contract signatures" }) + getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } - @Get(':id/summary') - @ApiOperation({ summary: 'Contract summary string for dashboard' }) - getSummary(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/summary") + @ApiOperation({ summary: "Contract summary string for dashboard" }) + getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } - @Post(':id/customer/sign') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated — use POST contract/sign)', + summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -644,20 +873,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -666,48 +896,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark in transit" }) + async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/complete') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark completed" }) + async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/cancel') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/consolidation') - @ApiOperation({ summary: 'Request freight consolidation' }) - requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Delete(":id/consolidation") + @ApiOperation({ summary: "Remove consolidation pairing" }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/consolidation") + @ApiOperation({ summary: "Get consolidation details" }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } } 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 5d7e3b2c9..2cc23b3fa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,7 +1,7 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -14,13 +14,13 @@ import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; -import { BookingPaymentController } from './booking-payment.controller'; -import { BookingPaymentService } from './booking-payment.service'; +// import { BookingPaymentController } from './booking-payment.controller'; +// import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingsController } from './bookings.controller'; -import { PayController } from './pay.controller'; +// import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; @@ -32,13 +32,15 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; -import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractsModule } from '../contracts/contracts.module'; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; + @Module({ imports: [ @@ -56,6 +58,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BillingModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), + forwardRef(() => ContractsModule), + forwardRef(() => ContractsModule), FilesModule, MinioModule, CompaniesModule, @@ -66,10 +70,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController, BookingPaymentController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, @@ -79,13 +83,17 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingTransitionService, BookingContractService, BookingInvoiceService, - BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + ], }) -export class BookingsModule {} +export class BookingsModule { } 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 57e811e34..b99611c35 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + hazardousQuantity?: number; + reeferQuantity?: number; weightResult: ContainerWeightResult; }>, ): Promise { @@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository { const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); + // A per-line breakdown can never exceed the line's own quantity. + const clamp = (v?: number) => + Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0)); const row = containerRepo.create({ bookingId, containerTypeId: item.containerTypeId, quantity: item.quantity, + hazardousQuantity: clamp(item.hazardousQuantity), + reeferQuantity: clamp(item.reeferQuantity), vgmPerUnitTons: item.vgmPerUnitTons, totalVgmTons: totalVgm, wagonsRequired, @@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** Bookings in any of the given statuses (clearance queue helpers). */ + async findByStatuses(statuses: string[]): Promise { + if (!statuses.length) return []; + return this.repository.find({ + where: { status: In(statuses) }, + order: { createdAt: 'DESC' }, + }); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; 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 a4636cbf7..80165cb7e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -45,6 +45,8 @@ import { import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -67,6 +69,17 @@ const NEEDS_ACTION_STATUSES = [ 'APPROVED_PENDING_SIGNATURE', ] as const; +/** + * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed + * the total cargo it's a portion of, and is never negative. + */ +function clampToCargo(value: number | undefined, cargoAmount: number): number { + const v = Number(value ?? 0); + if (!Number.isFinite(v) || v <= 0) return 0; + const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; + return Math.min(v, cap); +} + @Injectable() export class BookingsService { constructor( @@ -81,8 +94,62 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly contractPdfService: ContractPdfService, ) {} + async assignCustomerTruck( + bookingId: string, + dto: CustomerTruckAssignmentDto, + ): Promise { + const booking = await this.findById(bookingId); + const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); + const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.customerTruckAssignedAt) { + throw new ConflictException('Customer truck assignment is already submitted and locked'); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException('Booking must be paid before assigning an external customer truck'); + } + + await this.bookingsRepository.update(bookingId, { + status: 'TRUCK_ASSIGNED', + customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + customerTruckDriverName: dto.driverName.trim(), + customerTruckType: dto.truckType.trim(), + customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), + customerTruckAssignedAt: new Date(), + }); + + return this.findById(bookingId); + } + + async customerTruckFreightOrderCopies( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + if (!booking.customerTruckAssignedAt) { + throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); + } + + const html = this.buildCustomerTruckFreightOrderHtml(booking); + const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + return { + filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ private async resolveTradeDirectionForBooking( originYardId: string, @@ -120,6 +187,79 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } + private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + const assignedAt = booking.customerTruckAssignedAt + ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') + : '-'; + const rows: Array<[string, string | null | undefined]> = [ + ['Booking Reference', booking.reference], + ['Client Name', booking.company?.name], + ['Client ID', booking.companyId], + ['Trade Direction', booking.tradeDirection], + ['Freight Type', booking.freightType], + ['Truck Plate Number', booking.customerTruckPlateNumber], + ['Driver Name', booking.customerTruckDriverName], + ['Truck Type', booking.customerTruckType], + ['Container Number to Load', booking.customerTruckContainerNumber], + ['Assigned At', assignedAt], + ['Booking Status', booking.status], + ]; + const rowHtml = rows + .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) + .join(''); + const copy = (watermark: string) => ` +
+
${this.escapeHtml(watermark)}
+
+
+

Freight Order

+

Customer external truck assignment

+
+ ${this.escapeHtml(booking.reference)} +
+ ${rowHtml}
+
+
Customer / Carrier Signature
+
Port Operations Verification
+
Gate Security Verification
+
+
`; + + return ` + + + + + + + ${copy('Copy 1: Port Operations Copy')} + ${copy('Copy 2: Gate Security & Carrier Copy')} + + `; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + /** Build evaluation input from booking freight shape. */ /** * Whether a service type bundles customs clearance. This is the single source @@ -506,6 +646,16 @@ export class BookingsService { // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container + // freight tracks this per line, so these are 0 for CONTAINER. + bulkHazardousQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) + : 0, + bulkReeferQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) + : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -528,6 +678,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -665,6 +817,8 @@ export class BookingsService { containers, ); + const cargoAmount = + dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, @@ -675,6 +829,22 @@ export class BookingsService { freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for + // container freight (per-line on the containers instead). + bulkHazardousQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), + cargoAmount, + ) + : 0, + bulkReeferQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), + cargoAmount, + ) + : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -721,6 +891,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 35df9dcd3..c930d7aa1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty({ example: 'BULK_COFFEE' }) code!: string; - @ApiProperty() - show_free_text_box!: boolean; - @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) unit_of_measure?: CargoUnitOfMeasure | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 9380faba5..c4d971f51 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -52,6 +52,28 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value)) vgmPerUnitTons!: number; + + @ApiPropertyOptional({ + description: 'How many of this line are hazardous (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ + description: 'How many of this line are refrigerated (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number; } /** @@ -320,6 +342,25 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + /** + * Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's + * unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed + * cargoTotalWeightVgm. Ignored for container freight (per-line on containers). + */ + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkReeferQuantity?: number; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts new file mode 100644 index 000000000..9daf7523e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; + +export const CUSTOMER_TRUCK_TYPES = [ + 'Flatbed', + 'Container Chassis', + 'Lowboy', + 'Box Truck', + 'Tipper', +] as const; + +export class CustomerTruckAssignmentDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(16) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', + }) + containerNumberToLoad!: string; +} 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 19aa3a199..910d3f103 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 @@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [ // Road (truck) drawdown orders skip the train batch pool and wait here for // truck dispatch after Marketing accepts; billed by KM, not wagons. 'ROAD_DISPATCH_PENDING', + 'TRUCK_ASSIGNED', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before @@ -260,6 +261,24 @@ export class Booking extends BaseEntity { @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) lastMileDeliveryLng?: number | null; + @Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true }) + customerTruckPlateNumber?: string | null; + + @Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true }) + customerTruckDriverName?: string | null; + + @Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true }) + customerTruckType?: string | null; + + @Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true }) + customerTruckContainerNumber?: string | null; + + @Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true }) + customerTruckAssignedAt?: Date | null; + + @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) + customerTruckArrivedAt?: Date | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; @@ -321,6 +340,19 @@ export class Booking extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** + * Bulk-only hazardous / reefer amount, in the cargo's own unit of measure + * (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of + * `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container + * freight carries this per line on `booking_container` instead, so these stay + * 0 for CONTAINER bookings. The booleans above remain the surcharge trigger. + */ + @Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkHazardousQuantity!: number; + + @Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkReeferQuantity!: number; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @@ -435,6 +467,25 @@ export class Booking extends BaseEntity { @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) glStationYardId?: string | null; + /** Per-booking phased clearance (GENERAL + customs). */ + @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) + clearanceCurrentPhase?: string | null; + + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; + /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts deleted file mode 100644 index 25f1927d3..000000000 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { BookingPaymentService } from './booking-payment.service'; -// import { BookingTransitionService } from './booking-transition.service'; -// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -// import { Booking } from './entities/booking.entity'; -// import { BookingNextStep } from './booking-next-step.util'; - -@ApiTags('payments') -@ApiBearerAuth() -@Controller('bookings') -export class PayController { - constructor( - private readonly paymentService: BookingPaymentService, - // private readonly transitionService: BookingTransitionService, - ) { } - - @Post(':id/payment/pay') - @ApiOperation({ summary: 'Complete in-app payment (mock)' }) - @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) - async pay(@Param('id', ParseUUIDPipe) id: string) { - return await this.paymentService.pay(id); - // const abstract = await this.transitionService.enrichBookingResponse(booking); - // return { ...abstract, paymentReceipt: receipt }; - } -} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts new file mode 100644 index 000000000..7ea818e34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +const generalImportBooking = { + id: 'b-general', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + customsClearingEnabled: true, + contractKind: 'GENERAL', + contractId: 'c-1', + dutyRequired: true, + roHoldReason: null, + vesselDepartureDate: null, +} as Booking; + +const generalExportBooking = { + ...generalImportBooking, + id: 'b-export', + tradeDirection: 'EXPORT', + dutyRequired: null, +} as Booking; + +function makeService(overrides?: { + booking?: Booking; + workflowThrows?: boolean; +}) { + const booking = overrides?.booking ?? generalImportBooking; + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(booking), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const filesService = { + upsertByCode: jest.fn().mockResolvedValue({}), + findByResource: jest.fn().mockResolvedValue([]), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + }; + const workflowService = { + assertPriorCompleteForBooking: overrides?.workflowThrows + ? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete')) + : jest.fn().mockResolvedValue(undefined), + completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), + onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), + listMilestonesForBooking: jest.fn().mockResolvedValue([]), + resolvePhaseForBooking: jest.fn().mockReturnValue(null), + computeNextActionForBooking: jest.fn().mockReturnValue(null), + isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false), + markReadyForOperation: jest.fn().mockResolvedValue(undefined), + onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined), + }; + const milestoneService = { + adviseDuty: jest.fn().mockResolvedValue(undefined), + }; + const dropdownSettingsService = { + getByCode: jest.fn().mockResolvedValue({ + children: [{ value: '2' }], + }), + }; + + const service = new BookingClearanceService( + bookingsRepository as never, + bookingsService as never, + filesService as never, + fileUploadSettingsService as never, + workflowService as never, + milestoneService as never, + dropdownSettingsService as never, + ); + + return { + service, + bookingsRepository, + bookingsService, + filesService, + workflowService, + milestoneService, + }; +} + +describe('BookingClearanceService', () => { + describe('adviseDuty', () => { + it('skips duty milestones when duty is not required', async () => { + const { service, workflowService, bookingsRepository } = makeService(); + await service.adviseDuty('b-general', { dutyRequired: false }); + + expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + dutyRequired: false, + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + + it('records duty advice when duty applies', async () => { + const { service, milestoneService } = makeService(); + await service.adviseDuty('b-general', { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }); + + expect(milestoneService.adviseDuty).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }), + undefined, + ); + }); + }); + + describe('uploadDutySlip', () => { + it('rejects when duty is not required', async () => { + const { service } = makeService({ + booking: { ...generalImportBooking, dutyRequired: false } as Booking, + }); + await expect( + service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => { + const { service, filesService, workflowService, bookingsRepository } = makeService(); + const file = { fieldname: 'file' } as Express.Multer.File; + + await service.uploadDutySlip('b-general', file); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'b-general', + code: 'duty_tax_receipt', + file, + }), + ); + expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith( + 'b-general', + 'DUTY_TAX_PAID', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + }); + + describe('uploadDeclaration', () => { + it('rejects when a prior milestone is incomplete', async () => { + const { service } = makeService({ workflowThrows: true }); + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('uploadReleaseOrder', () => { + it('places RO on hold when vessel departs too soon', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().slice(0, 10); + + const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); + const result = await service.uploadReleaseOrder( + 'b-export', + { fieldname: 'ro' } as Express.Multer.File, + dateStr, + ); + + expect(result.hold).toBe(true); + expect(result.holdReason).toMatch(/minimum lead time/i); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-export', + expect.objectContaining({ roHoldReason: expect.any(String) }), + ); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts new file mode 100644 index 000000000..7bb835df2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -0,0 +1,618 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; + +export interface BookingClearanceView { + bookingId: string; + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + operationReady?: boolean; + preClearanceFinalized?: boolean; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; +} + +@Injectable() +export class BookingClearanceService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + ) {} + + private async assertPhasedGeneralCustoms(booking: Booking): Promise { + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Phased clearance applies only to customs bookings.'); + } + if (booking.contractKind !== 'GENERAL') { + throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); + } + if (!booking.contractId) { + throw new BadRequestException('Booking is not linked to a contract.'); + } + } + + private async loadBooking(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.assertPhasedGeneralCustoms(booking); + return booking; + } + + async getClearanceView(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); + + const documents: BookingClearanceView['documents'] = []; + + const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); + const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); + const boundary = await this.workflowService.isBoundaryCompleteForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + const workflowFiles = buildWorkflowFiles( + files, + booking.tradeDirection ?? 'IMPORT', + ); + + return { + bookingId, + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: booking.dutyRequired ?? null, + roHold: Boolean(booking.roHoldReason), + roHoldReason: booking.roHoldReason ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt + ? booking.roAmendmentRequestedAt.toISOString() + : null, + operationReady: boundary, + preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + dutyAdvice, + workflowFiles, + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, + }; + } + + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + isPhasedGeneralCustomsBooking(booking: Booking): boolean { + return ( + Boolean(booking.customsClearingEnabled) && + booking.contractKind === 'GENERAL' && + Boolean(booking.contractId) + ); + } + + async uploadDeclaration( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const allApproved = await this.isClearanceFullyApproved(booking); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: + tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async adviseDuty( + bookingId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DUTY_TAXES_ADVISED', + ); + + await this.bookingsRepository.update(bookingId, { + dutyRequired: dto.dutyRequired, + clearanceCurrentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + } as never); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkippedForBooking(bookingId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDuty( + bookingId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import bookings.'); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async uploadTransitPermit( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'TRANSIT_PERMIT_UPLOADED', + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async finalizePreClearance(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); + } + + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (booking.preClearanceFinalizedAt) { + return this.bookingsService.findById(bookingId); + } + + await this.bookingsRepository.update(bookingId, { + preClearanceFinalizedAt: new Date(), + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async uploadDeliveryOrder( + bookingId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import bookings.'); + } + + if (!booking.preClearanceFinalizedAt) { + throw new BadRequestException( + 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', + ); + } + + await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'delivery_order', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + + return this.bookingsService.findById(bookingId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + bookingId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'release_order', + file, + }); + + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + } as never); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.bookingsRepository.update(bookingId, { + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + return { + booking: await this.bookingsService.findById(bookingId), + hold: true, + holdReason: reason, + }; + } + + await this.bookingsRepository.update(bookingId, { + roHoldReason: null, + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'RELEASE_ORDER_SECURED', + userId, + ); + + return { booking: await this.bookingsService.findById(bookingId), hold: false }; + } + + async requestRoAmendment( + bookingId: string, + note?: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export bookings.'); + } + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.bookingsRepository.update(bookingId, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + if (userId) { + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'CHANGES_REQUESTED', + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async confirmExportRelease(bookingId: string, userId?: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'EXPORT_RELEASED', + ); + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + return this.bookingsService.findById(bookingId); + } + + async etQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); + } + return filtered; + } + + async djQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...DJ_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if ( + belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { + roHoldReason: b.roHoldReason, + preClearanceFinalizedAt: b.preClearanceFinalizedAt, + }) + ) { + filtered.push(b); + } + } + return filtered; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..7a3695768 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 3389933a7..ce6f7bea4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -22,6 +22,11 @@ const IMPORT_DEFS: Record> = { DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, + TRANSIT_PERMIT_UPLOADED: { + label: 'Transit Permit Uploaded', + ownerRegion: 'ET', + triggeredByDoc: true, + }, DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, @@ -52,6 +57,11 @@ const EXPORT_DEFS: Record> = { FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + EXPORT_TRANSPORT_ISSUED: { + label: 'Export Transport Document Issued', + ownerRegion: 'ET', + triggeredByDoc: true, + }, CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 947fc6979..7d30b1c5b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -39,6 +39,15 @@ export class ClearanceMilestoneService { }); } + /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */ + async seedPreBookingMilestonesOnBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + await this.seed(preBooking, { bookingId }); + } + /** Seed the post-booking milestones onto a freshly created booking. */ async seedPostBookingMilestones( bookingId: string, @@ -94,7 +103,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -178,7 +187,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -187,6 +196,99 @@ export class ClearanceMilestoneService { return this.repo.save(milestone); } + /** Skip optional milestones (e.g. duty when not required). */ + /** Reopen a completed contract milestone so review can continue after a query. */ + async reopenForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + /** Reopen a completed booking milestone so review can continue after a query. */ + async reopenForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + async skipForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async skipForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async completeWithMetadataForBooking( + bookingId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata(bookingId, code, metadata, userId, note); + } + + /** Complete a contract milestone with structured metadata (duty advice, etc.). */ + async completeWithMetadataForContract( + contractId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + async adviseDutyForContract( + contractId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + ): Promise { + return this.completeWithMetadataForContract( + contractId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + ); + } + /** Complete a doc-triggered milestone when its document is uploaded/approved. */ async completeByDocTrigger( scope: { bookingId?: string; contractId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts new file mode 100644 index 000000000..f30b64597 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -0,0 +1,331 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import type { Contract } from './entities/contract.entity'; +import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; + +function ms( + code: string, + status: 'PENDING' | 'COMPLETED' | 'SKIPPED', + ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', +): ClearanceMilestone { + return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; +} + +function importThroughDeclaration(): ClearanceMilestone[] { + return [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), + ms('DECLARED', 'PENDING', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; +} + +function makeService(milestones: ClearanceMilestone[]) { + const contractsRepository = { + currentCycle: jest.fn(), + update: jest.fn(), + setCycleStatus: jest.fn(), + updateCycle: jest.fn(), + }; + const milestoneService = { + listForContract: jest.fn().mockResolvedValue(milestones), + listForBooking: jest.fn().mockResolvedValue(milestones), + skipForContract: jest.fn(), + completeForContract: jest.fn(), + completeWithMetadataForContract: jest.fn(), + }; + const bookingsRepository = { update: jest.fn() }; + const service = new ClearanceWorkflowService( + contractsRepository as never, + milestoneService as never, + bookingsRepository as never, + ); + return { service, milestoneService, contractsRepository, bookingsRepository }; +} + +const importContract = { + id: 'c-import', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +const exportContract = { + id: 'c-export', + tradeDirection: 'EXPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +describe('ClearanceWorkflowService', () => { + describe('boundaryMilestone', () => { + it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { + const { service } = makeService([]); + expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); + expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); + }); + }); + + describe('assertPriorComplete', () => { + it('rejects when a prior milestone is still pending', async () => { + const milestones = importThroughDeclaration().map((m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' + ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') + : m, + ); + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows proceeding when prior milestones are completed or skipped', async () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBoundaryComplete', () => { + it('returns true only when boundary milestone is completed', async () => { + const done = [ + ...importThroughDeclaration().slice(0, -1), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service: doneSvc } = makeService(done); + await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); + + const pending = importThroughDeclaration(); + const { service: pendingSvc } = makeService(pending); + await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); + }); + }); + + describe('onDutySkipped', () => { + it('skips duty milestones on the contract', async () => { + const { service, milestoneService } = makeService([]); + await service.onDutySkipped('c-import'); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAXES_ADVISED', + ); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAX_PAID', + ); + }); + }); + + describe('computeNextAction — import happy path', () => { + it('prompts customer to upload docs first', () => { + const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); + const next = service.computeNextAction(importContract, null, [ + ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), + ]); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); + }); + + it('prompts ET review after customer docs', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, null, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/Review/i); + }); + + it('prompts duty toggle when declaration done and duty unset', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: null } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/duty/i); + }); + + it('prompts customer duty slip when duty required and advised', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); + }); + + it('skips duty path when duty not required', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); + }); + + it('prompts ET to finalize pre-clearance after transit permit', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/finalize pre-clearance/i); + }); + + it('prompts DJ for DO then ET booking when pre-booking complete', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const djNext = service.computeNextAction(importContract, cycle, milestones); + expect(djNext?.actor).toBe('GL_DJ'); + + const booked = milestones.map((m) => + m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, + ); + const etNext = service.computeNextAction(importContract, cycle, booked); + expect(etNext?.actor).toBe('GL_ET'); + expect(etNext?.action).toMatch(/booking/i); + }); + }); + + describe('computeNextAction — export RO hold', () => { + it('surfaces DJ action when RO is on hold', () => { + const milestones = [ + ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), + ]; + const cycle = { + roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).', + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(exportContract, cycle, milestones); + expect(next?.actor).toBe('GL_DJ'); + expect(next?.blockedReason).toMatch(/minimum lead time/i); + }); + }); + + describe('inferPhase', () => { + it('places import contract in customer duty phase when duty outstanding', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const phase = service.inferPhase(importContract, cycle, milestones); + expect(phase).toBe(ContractDocPhase.CustomerDuty); + }); + }); + + describe('queue helpers', () => { + it('returns first pending ET-owned milestone code', () => { + const { service } = makeService([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]); + expect(service.etPendingMilestoneCodes([])).toBeNull(); + expect( + service.etPendingMilestoneCodes([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]), + ).toBe('DOCUMENTS_APPROVED'); + }); + + it('returns first pending DJ-owned milestone code', () => { + const { service } = makeService([]); + expect( + service.djPendingMilestoneCodes([ + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]), + ).toBe('DO_COLLECTED'); + }); + }); + + describe('computeNextActionForBooking', () => { + it('prompts customer to proceed after import boundary on booking', () => { + const booking = { + tradeDirection: 'IMPORT', + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as Booking; + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextActionForBooking(booking, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.action).toMatch(/operation/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts new file mode 100644 index 000000000..758da27ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -0,0 +1,566 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { MilestoneMetadata } from './entities/clearance-milestone.entity'; +import { splitMilestones } from './clearance-milestone.catalog'; +import { Contract } from './entities/contract.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import type { ClearanceMetaState } from './clearance-workflow.types'; +import { metaFromBooking } from './clearance-workflow.types'; + +export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; + +export interface ClearanceNextAction { + actor: ClearanceActorRole; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; +} + +const IMPORT_BOUNDARY = 'DO_COLLECTED'; +const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; + +const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; +const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; + +@Injectable() +export class ClearanceWorkflowService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly milestoneService: ClearanceMilestoneService, + private readonly bookingsRepository: BookingsRepository, + ) {} + + boundaryMilestone(tradeDirection: string): string { + return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; + } + + // ── Contract scope (ONE_TIME) ───────────────────────────────────────────── + + async listMilestones(contractId: string): Promise { + return this.milestoneService.listForContract(contractId); + } + + async listMilestonesForBooking(bookingId: string): Promise { + return this.milestoneService.listForBooking(bookingId); + } + + async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestones(contractId), + tradeDirection, + ); + } + + async isBoundaryCompleteForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + ); + } + + private isBoundaryCompleteForMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + ): boolean { + const code = this.boundaryMilestone(tradeDirection); + const m = milestones.find((x) => x.milestoneCode === code); + return m?.status === 'COMPLETED'; + } + + async assertBoundaryComplete(contract: Contract): Promise { + const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); + if (!ok) { + throw new BadRequestException( + `Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, + ); + } + } + + async assertPriorComplete( + contractId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestones(contractId), + tradeDirection, + targetCode, + ); + } + + async assertPriorCompleteForBooking( + bookingId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + targetCode, + ); + } + + private async assertPriorCompleteOnMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + targetCode: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const targetIdx = preBooking.findIndex((d) => d.code === targetCode); + if (targetIdx < 0) return; + + for (let i = 0; i < targetIdx; i++) { + + const code = preBooking[i]!.code; + const m = byCode.get(code); + if (!m) continue; + if (m.status === 'SKIPPED') continue; + if (m.status !== 'COMPLETED') { + throw new BadRequestException( + `Complete "${preBooking[i]!.label}" before proceeding.`, + ); + } + } + } + + async skipMilestones(contractId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForContract(contractId, code); + } + } + + async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForBooking(bookingId, code); + } + } + + async completeMilestone( + contractId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForContract( + contractId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForContract(contractId, code, userId); + } + + async completeMilestoneForBooking( + bookingId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForBooking( + bookingId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForBooking(bookingId, code, userId); + } + + async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestone(contractId, uploaded); + await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onCustomerDocsUploadedForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestoneForBooking(bookingId, uploaded); + await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onAllDocsApproved(contractId: string): Promise { + await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); + } + + async onAllDocsApprovedForBooking(bookingId: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + /** Customer doc queried or re-uploaded — document approval milestone must reopen. */ + async onDocumentReviewReopened(contractId: string): Promise { + await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDocumentReviewReopenedForBooking(bookingId: string): Promise { + await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + async onDeclarationUploaded(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestone(contractId, 'DECLARED', userId); + } + + async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId); + } + + async onDutySkipped(contractId: string): Promise { + await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onDutySkippedForBooking(bookingId: string): Promise { + await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onExportReleased(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId); + await this.markReadyForBooking(contractId); + } + + async onExportReleasedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId); + await this.markReadyForOperation(bookingId); + } + + async markReadyForBooking(contractId: string): Promise { + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + } + + /** GENERAL per-booking: boundary complete → customer may proceed to operations. */ + async markReadyForOperation(bookingId: string): Promise { + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + } + + resolvePhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const meta: ClearanceMetaState = { + dutyRequired: cycle?.dutyRequired ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + currentPhase: cycle?.currentPhase ?? null, + }; + return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones); + } + + resolvePhaseForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.resolvePhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private resolvePhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + if (meta.currentPhase) { + return meta.currentPhase as ContractDocPhase; + } + return this.inferPhaseFromMeta(tradeDirection, meta, milestones); + } + + inferPhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.inferPhaseFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + }, + milestones, + ); + } + + inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase { + return this.inferPhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private inferPhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const isDone = (code: string) => + byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED'; + + const docUploaded = + tradeDirection === 'IMPORT' + ? isDone(IMPORT_DOC_UPLOADED) + : isDone(EXPORT_DOC_UPLOADED); + + if (!docUploaded) return ContractDocPhase.CustomerIntake; + if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return ContractDocPhase.GlDjCollection; + } + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; + return ContractDocPhase.GlEtPostClearance; + } + + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + return ContractDocPhase.CustomerDuty; + } + if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; + if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance; + if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlEtPostClearance; + } + + computeNextAction( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null, + }, + milestones, + 'contract', + ); + } + + computeNextActionForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + 'booking', + ); + } + + private computeNextActionFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + terminalScope: 'contract' | 'booking', + ): ClearanceNextAction | null { + if (meta.roHoldReason) { + return { + actor: 'GL_DJ', + action: 'Re-upload Release Order or request port amendment', + milestoneCode: 'RELEASE_ORDER_SECURED', + blockedReason: meta.roHoldReason, + }; + } + + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const pending = (code: string) => { + const m = byCode.get(code); + return m && m.status === 'PENDING'; + }; + const isDone = (code: string) => { + const m = byCode.get(code); + return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; + }; + + const docCode = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + + if (pending(docCode) || !isDone(docCode)) { + return { + actor: 'CUSTOMER', + action: 'Upload clearance documents', + milestoneCode: docCode, + }; + } + + if (!isDone('DOCUMENTS_APPROVED')) { + return { + actor: 'GL_ET', + action: 'Review and approve customer documents', + milestoneCode: 'DOCUMENTS_APPROVED', + }; + } + + const terminalAction = + terminalScope === 'contract' + ? 'Create shipment booking' + : 'Proceed to request operation'; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + if (!isDone(EXPORT_BOUNDARY)) { + return { + actor: 'GL_ET', + action: 'Confirm export release', + milestoneCode: EXPORT_BOUNDARY, + }; + } + if (terminalScope === 'booking') { + if (!isDone('FREIGHT_PAYMENT_SETTLED')) { + return { + actor: 'CUSTOMER', + action: 'Pay freight charges', + milestoneCode: 'FREIGHT_PAYMENT_SETTLED', + }; + } + if (!isDone('WAGON_ALLOCATED')) { + return { + actor: 'OPERATIONS', + action: 'Allocate wagon', + milestoneCode: 'WAGON_ALLOCATED', + }; + } + if (!isDone('EXPORT_TRANSPORT_ISSUED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit', + milestoneCode: 'EXPORT_TRANSPORT_ISSUED', + }; + } + return null; + } + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: EXPORT_BOUNDARY, + }; + } + + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + + if (meta.dutyRequired === null || meta.dutyRequired === undefined) { + return { + actor: 'GL_ET', + action: 'Set whether duty/tax applies', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + + if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (!isDone('DUTY_TAXES_ADVISED')) { + return { + actor: 'GL_ET', + action: 'Advise duty and tax amount', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + return { + actor: 'CUSTOMER', + action: 'Upload duty/tax payment slip', + milestoneCode: 'DUTY_TAX_PAID', + }; + } + + if (!isDone('TRANSIT_PERMIT_UPLOADED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit screenshot', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!meta.preClearanceFinalizedAt) { + return { + actor: 'GL_ET', + action: 'Finalize pre-clearance', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!isDone(IMPORT_BOUNDARY)) { + return { + actor: 'GL_DJ', + action: 'Upload Delivery Order', + milestoneCode: IMPORT_BOUNDARY, + }; + } + + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: IMPORT_BOUNDARY, + }; + } + + etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); + return pending?.milestoneCode ?? null; + } + + djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ'); + return pending?.milestoneCode ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts new file mode 100644 index 000000000..25d01b17e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts @@ -0,0 +1,33 @@ +import type { ContractDocPhase } from '@edr/types'; + +/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */ +export interface ClearanceMetaState { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + currentPhase?: ContractDocPhase | string | null; + preClearanceFinalizedAt?: Date | null; +} + +export type ClearanceScope = + | { kind: 'contract'; contractId: string } + | { kind: 'booking'; bookingId: string }; + +export function metaFromBooking(booking: { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + clearanceCurrentPhase?: string | null; + preClearanceFinalizedAt?: Date | null; +}): ClearanceMetaState { + return { + dutyRequired: booking.dutyRequired ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null, + roHoldReason: booking.roHoldReason ?? null, + currentPhase: booking.clearanceCurrentPhase ?? null, + preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null, + }; +} 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 b077930b7..b0a5cc636 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 @@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ @@ -56,6 +57,7 @@ export class ContractBookingService { private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, + private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, ) {} @@ -194,9 +196,11 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed post-booking milestones on the booking (no - // cycle needed) and leave the contract active. The booking now drives its - // own clearance via the booking-level pipeline. + // Per-booking clearance: seed full milestone timeline on the booking. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); await this.milestoneService.seedPostBookingMilestones( booking.id, contract.tradeDirection, @@ -246,10 +250,14 @@ export class ContractBookingService { } return 'GL_ET'; } - // ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. - if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { + // ONE_TIME customs — pre-booking boundary milestone must be complete. + const boundaryOk = await this.workflowService.isBoundaryComplete( + contract.id, + contract.tradeDirection, + ); + if (!boundaryOk) { throw new BadRequestException( - 'Contract clearance is not ready for booking yet.', + 'Pre-booking clearance is not complete — booking cannot be created yet.', ); } return 'GL_ET'; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 71cfa0580..a09de407c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,13 +1,23 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; +import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface ContractClearanceDocument { fileKey: string; @@ -34,6 +44,39 @@ export interface ContractClearanceView { outputCode: string | null; documents: ContractClearanceDocument[]; allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + bookingReady?: boolean; + preClearanceFinalized?: boolean; + /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ + exportClearanceFinalized?: boolean; + linkedBookingId?: string | null; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; } @Injectable() @@ -41,13 +84,57 @@ export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, + private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, ) {} + private isPhasedCustoms(contract: Contract): boolean { + return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; + } + + private assertPhasedCustoms(contract: Contract): void { + if (!this.isPhasedCustoms(contract)) { + throw new BadRequestException( + 'Phased clearance (Phase 1) applies to one-time customs contracts.', + ); + } + } + + /** + * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing + * phased milestones. Revert that state so declaration / DO steps can proceed. + */ + private async reconcilePrematureBookingReady( + contractId: string, + contract: Contract, + bookingReady: boolean, + ): Promise { + if ( + !this.isPhasedCustoms(contract) || + contract.status !== 'CLEARANCE_READY_FOR_BOOKING' || + bookingReady + ) { + return contract; + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + return this.contractsService.findById(contractId); + } + /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { - const contract = await this.contractsService.findById(contractId); + let contract = await this.contractsService.findById(contractId); const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); @@ -109,6 +196,47 @@ export class ContractClearanceService { } const allApproved = await this.isClearanceFullyApproved(contract); + const milestones = await this.workflowService.listMilestones(contractId); + let boundary = await this.workflowService.isBoundaryComplete( + contractId, + contract.tradeDirection, + ); + contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); + const phase = this.workflowService.resolvePhase(contract, cycle, milestones); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + let workflowFiles = buildWorkflowFiles( + files, + contract.tradeDirection ?? 'IMPORT', + ); + if (cycle?.bookingId) { + const bookingFiles = await this.filesService.findByResource( + cycle.bookingId, + 'bookings', + ); + const bookingWorkflow = buildWorkflowFiles( + bookingFiles, + contract.tradeDirection ?? 'IMPORT', + ); + const byCode = new Map(workflowFiles.map((f) => [f.code, f])); + for (const row of bookingWorkflow) { + if (row.file) byCode.set(row.code, row); + } + workflowFiles = [...byCode.values()]; + } + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const booking = await this.bookingsService.findById(cycle.bookingId); + if (booking) { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } + } return { contractId, @@ -120,6 +248,55 @@ export class ContractClearanceService { outputCode, documents, allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: cycle?.dutyRequired ?? null, + roHold: Boolean(cycle?.roHoldReason), + roHoldReason: cycle?.roHoldReason ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt + ? cycle.roAmendmentRequestedAt.toISOString() + : null, + bookingReady: boundary, + preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + exportClearanceFinalized: Boolean(cycle?.completedAt), + linkedBookingId: cycle?.bookingId ?? null, + dutyAdvice, + workflowFiles, + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): ContractClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, }; } @@ -154,6 +331,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -209,8 +439,16 @@ export class ContractClearanceService { clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle) { - await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { + currentPhase: ContractDocPhase.GlEtReview, + }); } + + if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { + await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + await this.workflowService.onDocumentReviewReopened(contractId); + } + return this.contractsService.findById(contractId); } @@ -294,25 +532,22 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has - // dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips - // the contract to "awaiting" (the customer must re-upload), but the reviewer - // may still be working through the rest of the batch. Restricting to - // UNDER_REVIEW only would 409 every review after the first query. - if ( - contract.status !== 'CLEARANCE_UNDER_REVIEW' && - contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' - ) { - throw new ConflictException( - `Cannot review clearance documents on status "${contract.status}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } const { inputCode, outputCode } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); + if ( + status === 'QUERIED' && + this.isPhasedCustoms(contract) && + cycle?.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, @@ -348,6 +583,33 @@ export class ContractClearanceService { if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onDocumentReviewReopened(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtReview, + }); + } + } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); + const refreshed = await this.contractsService.findById(contractId); + if ( + refreshed.customsClearingEnabled && + refreshed.contractKind === 'ONE_TIME' && + (await this.isClearanceFullyApproved(refreshed)) + ) { + await this.workflowService.onAllDocsApproved(contractId); + const c = await this.contractsRepository.currentCycle(contractId); + if (c) { + await this.contractsRepository.updateCycle(c.id, { + currentPhase: + refreshed.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + } } return this.contractsService.findById(contractId); @@ -359,11 +621,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -384,8 +642,10 @@ export class ContractClearanceService { /** * GL ET finalizes Path B pre-booking clearance: requires every customer - * document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING - * (GL then creates the booking). Rejects self-clearance (Path A) contracts. + * document APPROVED. For phased customs (ONE_TIME), document review completes + * here — booking readiness is set only after delivery order (import) or export + * release via the milestone workflow. Non-phased customs still jump straight to + * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts. */ async finalize(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -394,11 +654,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -407,6 +663,24 @@ export class ContractClearanceService { ); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onAllDocsApproved(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.contractsService.findById(contractId); + } + const { outputCode } = contractClearanceCodes(contract); if (outputCode) { const setting = await this.fileUploadSettingsService.getByCode(outputCode); @@ -452,11 +726,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -479,19 +749,14 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract that still needs - * customs clearance — awaiting the customer's documents, under GL review, or - * finalized and waiting for the customer to create the booking in the portal. + * GL ET clearance hub: every customs (Path B) contract in phased clearance, + * including after booking is created. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - ], + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -536,4 +801,492 @@ export class ContractClearanceService { sortOrder: filter.sortOrder ?? 'DESC', }); } + + // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── + + /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + private async ensureDeclarationPrerequisites( + contractId: string, + contract: Contract, + ): Promise { + const allApproved = await this.isClearanceFullyApproved(contract); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestones(contractId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApproved(contractId); + } + } + + async uploadDeclaration( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + await this.ensureDeclarationPrerequisites(contractId, contract); + await this.workflowService.assertPriorComplete( + contractId, + contract.tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.onDeclarationUploaded(contractId, userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + }); + } + + return this.contractsService.findById(contractId); + } + + async adviseDuty( + contractId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + dutyRequired: dto.dutyRequired, + currentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + }); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkipped(contractId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDutyForContract( + contractId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.contractsService.findById(contractId); + } + + async uploadDutySlip( + contractId: string, + file: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async uploadTransitPermit( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async finalizePreClearance(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import contracts.'); + } + + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (cycle.preClearanceFinalizedAt) { + return this.contractsService.findById(contractId); + } + + await this.contractsRepository.updateCycle(cycle.id, { + preClearanceFinalizedAt: new Date(), + currentPhase: ContractDocPhase.GlDjCollection, + }); + + return this.contractsService.findById(contractId); + } + + async uploadDeliveryOrder( + contractId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.preClearanceFinalizedAt) { + throw new BadRequestException( + 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', + ); + } + + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'delivery_order', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + + return this.contractsService.findById(contractId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + contractId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'release_order', + file, + }); + + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + }); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; + } + + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: null, + currentPhase: ContractDocPhase.GlEtOutput, + }); + await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + + return { contract: await this.contractsService.findById(contractId), hold: false }; + } + + async requestRoAmendment( + contractId: string, + note?: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.contractsRepository.updateCycle(cycle.id, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + + if (userId) { + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CHANGES_REQUESTED', + userId, + 'GL_DJ', + ); + } + + return this.contractsService.findById(contractId); + } + + async confirmExportRelease(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); + + await this.workflowService.onExportReleased(contractId, userId); + return this.contractsService.findById(contractId); + } + + /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ + async finalizeExportClearance(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export clearance finalize applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.bookingId) { + throw new BadRequestException( + 'A shipment booking must exist before export clearance can be finalized.', + ); + } + if (cycle.completedAt) { + return this.contractsService.findById(contractId); + } + + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const transportDone = bookingMilestones.some( + (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', + ); + if (!transportDone) { + throw new BadRequestException( + 'Upload the transit permit before finalizing export clearance.', + ); + } + + await this.contractsRepository.updateCycle(cycle.id, { + completedAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + + void userId; + return this.contractsService.findById(contractId); + } + + /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ + async etQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } + + /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ + async djQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...DJ_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const cycle = await this.contractsRepository.currentCycle(c.id); + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { + filtered.push(c); + } + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..e87112bc2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -172,15 +172,11 @@ export class ContractTransitionService { const cargoTypeId = (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; - // US-06 routing: bulk always needs director approval; container needs it only - // when its cargo type flags it. Resolve the chain via the same approval_rules - // source of truth the booking flow uses (no booking row is created here). - let requiresDirectorApproval = contract.freightType === 'BULK'; + // Resolve the chain from the cargo type flag only. + let requiresDirectorApproval = false; if (cargoTypeId) { const cargoType = await this.cargoTypesService.findById(cargoTypeId); - if (cargoType?.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false; } const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); @@ -345,6 +341,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ef013347..5e712a177 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,13 +9,16 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, + UploadedFile, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -40,11 +43,13 @@ import { ContractsService } from './contracts.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { BookingsService } from '../bookings/bookings.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -70,6 +75,10 @@ import { CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from './dto/phased-clearance.dto'; @ApiTags('contracts') @Controller('contracts') @@ -85,6 +94,8 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly bookingClearanceService: BookingClearanceService, + private readonly bookingsService: BookingsService, ) {} // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── @@ -417,6 +428,26 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @@ -459,7 +490,10 @@ export class ContractsController { } @Post(':id/clearance/review') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @@ -489,11 +523,164 @@ export class ContractsController { @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) - @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) + @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) + uploadDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' }) + adviseContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + return this.clearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' }) + finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalizePreClearance(id); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) + uploadContractDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.clearanceService.uploadDutySlip(id, file); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) + uploadTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + uploadDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + uploadReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) + requestRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) + confirmExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); + } + + @Post(':id/clearance/finalize-export-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL ET finalizes export clearance after post-booking transit permit upload', + }) + finalizeExportClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); + } + + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) + etClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.etQueue(filter); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) + djClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.djQueue(filter); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -674,6 +861,18 @@ export class ContractsController { }); } + @Post('bookings/:bookingId/transport-document') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) + uploadTransportDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @@ -692,11 +891,16 @@ export class ContractsController { @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) - uploadDutySlip( + async uploadDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { - return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); + const file = (files ?? [])[0]; + const booking = await this.bookingsService.findById(bookingId); + if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + return this.bookingClearanceService.uploadDutySlip(bookingId, file); + } + return this.glOperationsService.uploadDutySlip(bookingId, file); } @Get('bookings/:bookingId/incidents') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index c595dbf5f..94f469112 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -18,6 +18,8 @@ import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -71,7 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). - BookingsModule, + forwardRef(() => BookingsModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -85,6 +87,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, GlOperationsService, @@ -103,6 +107,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, ], diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 354f02e8c..9e464db51 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository { cycleId: string, status: string, fields: Partial< - Pick + Pick< + ContractClearanceCycle, + | 'bookingId' + | 'clearanceReadyAt' + | 'completedAt' + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + > > = {}, ): Promise { await this.dataSource @@ -526,6 +536,25 @@ export class ContractsRepository extends BaseRepository { .update(cycleId, { status, ...fields } as never); } + async updateCycle( + cycleId: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + | 'status' + | 'preClearanceFinalizedAt' + | 'completedAt' + > + >, + ): Promise { + await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); + } + /** Link the GL-created booking to a clearance cycle. */ async linkBooking(cycleId: string, bookingId: string): Promise { await this.dataSource 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 70d2632fa..8d864aac7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -200,9 +200,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, - estimatedShipmentDate: dto.estimatedShipmentDate - ? new Date(dto.estimatedShipmentDate) - : null, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', @@ -347,9 +344,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; - if (dto.estimatedShipmentDate) { - updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); - } if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 4108986cb..ac404c9df 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -101,6 +101,13 @@ export class CreateBulkLineDto { @Min(0) @Transform(({ value }) => Number(value)) hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; } /** Shipment booking created under a contract (Path A customer, Path B GL ET). */ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 70dba1e90..b20b99575 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -4,7 +4,6 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsDateString, IsIn, IsNumber, IsOptional, @@ -219,14 +218,6 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; - @ApiPropertyOptional({ - description: 'Non-binding estimate from the wizard (NOT validated against departures)', - example: '2026-07-15T00:00:00.000Z', - }) - @IsOptional() - @IsDateString() - estimatedShipmentDate?: string; - @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts new file mode 100644 index 000000000..f48653822 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdviseContractDutyDto { + @ApiProperty({ description: 'Whether the customer must pay duty/tax' }) + @IsBoolean() + dutyRequired!: boolean; + + @ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Declaration / payment reference code' }) + @IsOptional() + @IsString() + declarationSerial?: string; +} + +export class ReleaseOrderDto { + @ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' }) + @IsString() + vesselDepartureDate!: string; +} + +export class RoAmendmentDto { + @ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index e29985f2b..3c101f58e 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -34,4 +34,25 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) completedAt?: Date | null; + + /** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */ + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + /** Export RO vessel departure date (Path B export). */ + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true }) + currentPhase?: string | null; + + /** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */ + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; } 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 e8f53f4c6..73ca3a65d 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 @@ -8,6 +8,7 @@ import { IncidentType, } from './entities/clearance-incident.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { persistExportTransportUploads } from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -21,6 +22,7 @@ const DOC_CODE_TO_MILESTONE: Record = { import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET + export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation }; /** @@ -158,4 +160,44 @@ export class GlOperationsService { } return { uploaded: files.length, completedMilestones }; } + + /** + * GL ET uploads export transport document after wagon allocation (export ONE_TIME). + */ + async uploadTransportDocument( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Transport document upload applies to export shipments only.'); + } + + const milestones = await this.milestoneService.listForBooking(bookingId); + const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonDone = + wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; + if (!wagonDone) { + throw new BadRequestException( + 'Wagon must be allocated before the transport document can be uploaded.', + ); + } + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistExportTransportUploads(this.filesService, bookingId, files); + + if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } + + await this.milestoneService.completeByDocTrigger( + { bookingId }, + 'EXPORT_TRANSPORT_ISSUED', + ); + + return { uploaded: true, milestoneCompleted: true }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts new file mode 100644 index 000000000..cf044d6a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -0,0 +1,125 @@ +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util'; + +describe('buildWorkflowFiles', () => { + const resourceFiles = [ + { code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' }, + { code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' }, + { + code: 'transit_permitted', + id: 'f-transit', + name: 'transit.png', + url: '/files/transit', + }, + { + code: 'duty_tax_notice', + id: 'f-duty', + name: 'notice.pdf', + url: '/files/duty', + }, + { code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' }, + ]; + + it('includes declaration and transit files even when they also appear in GL output document settings', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']), + ); + }); + + it('includes multi-file declaration uploads alongside catalog codes', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'declaration_0', + id: 'f-dec-0', + name: 'decl-a.pdf', + url: '/files/decl-a', + }, + { + code: 'declaration_1', + id: 'f-dec-1', + name: 'decl-b.pdf', + url: '/files/decl-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']), + ); + expect(result.find((f) => f.code === 'declaration_0')?.label).toBe( + 'Declaration document 1', + ); + }); + + it('includes multi-file import transit permit uploads', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'transit_permit_0', + id: 'f-tp-0', + name: 'permit-a.pdf', + url: '/files/tp-a', + }, + { + code: 'transit_permit_1', + id: 'f-tp-1', + name: 'permit-b.pdf', + url: '/files/tp-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']), + ); + expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1'); + }); + + it('does not include non-catalog customer document codes', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false); + }); +}); + +describe('belongsOnDjClearanceQueue', () => { + it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => { + expect( + belongsOnDjClearanceQueue( + 'IMPORT', + { preClearanceFinalizedAt: new Date('2026-01-01') }, + [], + ), + ).toBe(true); + }); + + it('keeps contracts with completed Djibouti milestones', () => { + expect( + belongsOnDjClearanceQueue('IMPORT', null, [ + { ownerRegion: 'DJ', status: 'COMPLETED' }, + ]), + ).toBe(true); + }); + + it('excludes import contracts still on Ethiopia-side clearance only', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + }); +}); + +describe('belongsOnEtClearanceQueue', () => { + it('keeps contracts once phased clearance milestones exist', () => { + expect( + belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]), + ).toBe(true); + }); + + it('excludes contracts with no clearance milestones', () => { + expect(belongsOnEtClearanceQueue([])).toBe(false); + }); +}); 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 new file mode 100644 index 000000000..89fbf797e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -0,0 +1,319 @@ +import { BadRequestException } from '@nestjs/common'; +import { + catalogEntriesForTradeDirection, + declarationFileLabel, + isDeclarationFileCode, + isImportTransitPermitFileCode, + isExportTransportFileCode, + exportTransportFileLabel, + transitPermitFileLabel, + type ClearanceWorkflowFile, +} from '@edr/types'; + +/** Require at least one declaration file in the upload batch. */ +export function assertDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } +} + +/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `declaration_${index}`, + })); +} + +type DeclarationFileStore = { + findByResource( + resourceId: string, + resource: string, + ): Promise>; + deleteByCode(resourceId: string, resource: string, code: string): Promise; + upload(input: { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; + }): Promise; +}; + +/** Replace all declaration files on a resource with a new multi-file upload batch. */ +export async function persistDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeclarationFieldNames(files); + assertDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `declaration_${index}`, + file, + }), + ), + ); +} + +/** Require at least one transit permit file in the upload batch. */ +export function assertTransitPermitFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ +export function normalizeTransitPermitFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `transit_permit_${index}`, + })); +} + +/** Replace all import transit permit files on a resource with a new multi-file batch. */ +export async function persistTransitPermitUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeTransitPermitFieldNames(files); + assertTransitPermitFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `transit_permit_${index}`, + file, + }), + ), + ); +} + +/** Require at least one export transport document in the upload batch. */ +export function assertExportTransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +export function normalizeExportTransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `export_transport_document_${index}`, + })); +} + +/** Replace all export transport documents on a booking with a new multi-file batch. */ +export async function persistExportTransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeExportTransportFieldNames(files); + assertExportTransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isExportTransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `export_transport_document_${index}`, + file, + }), + ), + ); +} + +export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { + if (typeof value === 'boolean') return value; + if (value === undefined || value === '') return false; + return value === 'true' || value === '1'; +} + +type DjQueueMilestone = { + ownerRegion?: string | null; + status: string; +}; + +type DjQueueCycle = { + preClearanceFinalizedAt?: Date | null; + roHoldReason?: string | null; +} | null | undefined; + +/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ +export function belongsOnDjClearanceQueue( + tradeDirection: string | null | undefined, + cycle: DjQueueCycle, + milestones: DjQueueMilestone[], + extras?: { + roHoldReason?: string | null; + preClearanceFinalizedAt?: Date | null; + }, +): boolean { + const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; + if (roHold) return true; + + const hasDjActivity = milestones.some( + (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), + ); + if (hasDjActivity) return true; + + const preFinalized = + cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; + if (tradeDirection === 'IMPORT' && preFinalized) return true; + + return false; +} + +/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ +export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { + return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); +} + +/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ +export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; + +/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'FULLY_EXECUTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'ROAD_DISPATCH_PENDING', + 'IN_TRANSIT', + 'PAID', + 'COMPLETED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ +export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; + +/** Build labeled phased-customs file rows from resource files. */ +export function buildWorkflowFiles( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + tradeDirection: string, +): ClearanceWorkflowFile[] { + const fileByCode = new Map( + files.filter((f) => f.code).map((f) => [f.code as string, f]), + ); + const out: ClearanceWorkflowFile[] = []; + const included = new Set(); + + for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { + const file = fileByCode.get(entry.code) ?? null; + if (!file) continue; + included.add(entry.code); + out.push({ + code: entry.code, + label: entry.label, + uploadedBy: entry.uploadedBy, + category: entry.category, + file: { id: file.id, name: file.name, url: file.url }, + }); + } + + const extraDeclarations = files + .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: declarationFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + if (tradeDirection === 'IMPORT') { + const extraTransit = files + .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraTransit.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: transitPermitFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + if (tradeDirection === 'EXPORT') { + const extraExportTransport = files + .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraExportTransport.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: exportTransportFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + return out; +} 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 4bf8362f9..ec1fb6fa9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -61,6 +61,14 @@ export class FilesService { return this.upload(input); } + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.filesRepository.deleteByCode(resourceId, resource, code); + } + async uploadMany( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index e2535083e..45e9f5b1a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; @@ -58,4 +58,9 @@ export class CreateFirstMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 253d2d4c8..27dcfef87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -35,6 +35,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index c63a4c9e1..22520aac1 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -48,7 +48,7 @@ export class FirstMileInvoiceService { } // Fetch the booking to get the companyId and companyProfileId - const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } })); if (!fm) return null; if (!fm.booking?.companyId) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 6bb307a4b..680bb5d09 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -21,6 +21,9 @@ import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -30,7 +33,9 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - ) {} + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService + ) { } @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -80,7 +85,34 @@ export class FirstMileController { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.FirstMile, + sourceId: record.id, + type: "FIRST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "FIRST_MILE", + description: "First Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); await this.firstMileInvoiceService.ensureInvoiceFor(record); } return record; diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index a69c920f1..799ae14e6 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -16,7 +16,7 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), - BillingModule, + forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 08cd9ab10..ae0ada831 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -12,6 +12,8 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileRepository } from './first-mile.repository'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InvoiceEventPayload } from '../billing/billing.service'; type FirstMileListFilter = { status?: FirstMileStatus; @@ -146,6 +148,18 @@ export class FirstMileService { }; } + @OnEvent("firstmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { @@ -175,6 +189,7 @@ export class FirstMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } @@ -207,6 +222,7 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -215,7 +231,8 @@ export class FirstMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator'; +import { PaymentMethod } from '../entities/fuel-purchase.entity'; + +export class CreateFuelPurchaseDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + purchaseDate!: string; + + @IsNumber() + liters!: number; + + @IsNumber() + costPerLiter!: number; + + @IsOptional() + @IsString() + fuelStation?: string; + + @IsEnum(PaymentMethod) + @IsOptional() + paymentMethod?: PaymentMethod; + + @IsOptional() + @IsNumber() + odometerReading?: number; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsString() + receiptNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..aabafd17c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'fuel_consumption', schema: 'freight' }) +@Index(['vehicleId', 'month']) +export class FuelConsumption extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'month', type: 'date' }) + month!: Date; + + @Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 }) + totalLiters!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; + + @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) + fuelEfficiencyKmPerL?: number; + + @Column({ name: 'number_of_purchases', type: 'integer', default: 0 }) + numberOfPurchases!: number; + + @Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true }) + averageCostPerLiter?: number; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum PaymentMethod { + CASH = 'CASH', + CARD = 'CARD', + FUEL_CARD = 'FUEL_CARD', + TRANSFER = 'TRANSFER', + CHEQUE = 'CHEQUE', +} + +@Entity({ name: 'fuel_purchases', schema: 'freight' }) +export class FuelPurchase extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'purchase_date', type: 'timestamptz' }) + purchaseDate!: Date; + + @Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 }) + liters!: number; + + @Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 }) + costPerLiter!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'fuel_station', nullable: true }) + fuelStation?: string; + + @Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH }) + paymentMethod!: PaymentMethod; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @Column({ name: 'receipt_number', nullable: true }) + receiptNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..62207bfa1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,60 @@ +import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { FuelService } from './fuel.service'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@ApiTags('Fuel Management') +@Controller('fuel') +export class FuelController { + constructor(private readonly fuelService: FuelService) {} + + @Post('purchases') + @ApiOperation({ summary: 'Record fuel purchase' }) + async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) { + return this.fuelService.recordFuelPurchase(dto); + } + + @Get('purchases') + @ApiOperation({ summary: 'Get all fuel purchases' }) + async getAllFuelPurchases() { + return this.fuelService.getAllFuelPurchases(); + } + + @Get('purchases/:vehicleId') + @ApiOperation({ summary: 'Get fuel purchases for vehicle' }) + async getFuelPurchases( + @Param('vehicleId') vehicleId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.fuelService.getFuelPurchases( + vehicleId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('consumption/:vehicleId/:month') + @ApiOperation({ summary: 'Get monthly fuel consumption' }) + async getMonthlyConsumption( + @Param('vehicleId') vehicleId: string, + @Param('month') month: string, + ) { + return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month)); + } + + @Get('stats') + @ApiOperation({ summary: 'Get fleet-wide fuel statistics' }) + async getFleetFuelStats(@Query('months') months: number = 12) { + return this.fuelService.getFleetFuelStats(months); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get fuel statistics for vehicle' }) + async getVehicleFuelStats( + @Param('vehicleId') vehicleId: string, + @Query('months') months: number = 12, + ) { + return this.fuelService.getVehicleFuelStats(vehicleId, months); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FuelController } from './fuel.controller'; +import { FuelService } from './fuel.service'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])], + controllers: [FuelController], + providers: [FuelService, FuelRepository], + exports: [FuelService], +}) +export class FuelModule {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..d062c38e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.purchaseRepository.find({ + where: { + vehicleId, + purchaseDate: Between(startDate, endDate), + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + let consumption = await this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = this.consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..54c157c2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.purchaseRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.purchaseRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getAllFuelPurchases(): Promise { + return this.purchaseRepository + .createQueryBuilder('purchase') + .leftJoinAndSelect('purchase.vehicle', 'vehicle') + .orderBy('purchase.purchaseDate', 'DESC') + .getMany(); + } + + async getFuelPurchases( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.fuelRepository.getMonthlyConsumption(vehicleId, month); + } + + async getFleetFuelStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.purchaseRepository + .createQueryBuilder('purchase') + .where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + averageEfficiency: 0, // Placeholder - would need distance data + dateRange: { startDate, endDate }, + }; + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); + + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index 4f6f5fc8f..08de632f1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; @@ -58,4 +58,9 @@ export class CreateLastMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1747e308c..85d01b7f0 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -35,6 +35,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 929d97a3e..88a96e6c2 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -21,6 +21,9 @@ import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; +import { Freight } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @@ -30,6 +33,8 @@ export class LastMileController { constructor( private readonly lastMileService: LastMileService, private readonly lastMileInvoiceService: LastMileInvoiceService, + private readonly billingService: BillingService, + private readonly bookingsService: BookingsService ) {} @Get() @@ -80,9 +85,36 @@ export class LastMileController { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } + const booking = await this.bookingsService.findById(record.bookingId); + if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.LastMile, + sourceId: record.id, + type: "LAST_MILE", + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: "ETB", + + lines: [ + { + chargeType: "LAST_MILE", + description: "Last Mile Transportation Service", + quantity: 1, + unitRate: record.remainingPayment, + amount: record.remainingPayment, + currency: "ETB", + }, + ], + + subtotalAmount: record.remainingPayment, + taxAmount: 0, // Replace if VAT/tax applies + totalAmount: record.remainingPayment, + + dueInDays: 7, + status: Freight.InvoiceStatus.Pending, + }); + await this.lastMileInvoiceService.ensureInvoiceFor(record); + } return record; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5faad49b9..df439f28f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -10,6 +10,8 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileRepository } from './last-mile.repository'; +import { InvoiceEventPayload } from '../billing/billing.service'; +import { OnEvent } from '@nestjs/event-emitter'; type LastMileListFilter = { status?: LastMileStatus; @@ -129,6 +131,12 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { + const [existing] = await this.lastMileRepository.findAll({ + where: { bookingId: dto.bookingId }, + take: 1, + }); + if (existing) return existing; + return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', @@ -137,12 +145,26 @@ export class LastMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); } + @OnEvent("lastmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); + this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + } catch (err) { + this.logger.error( + `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -151,7 +173,8 @@ export class LastMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..de5ff08f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@ApiTags('Maintenance Management') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Post('schedules') + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats') + @ApiOperation({ summary: 'Get fleet-wide maintenance statistics' }) + async getFleetStats() { + return this.maintenanceService.getFleetMaintenanceStats(); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..a0227a733 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], + providers: [MaintenanceService, MaintenanceRepository], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..e804243a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getFleetMaintenanceStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.costRepository + .createQueryBuilder('cost') + .where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0db38a751..57c95eab3 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -4,22 +4,22 @@ import { HttpCode, HttpStatus, Post, - UseGuards, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { Public } from "@edr/api-common"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; import { PaymentService } from "./payment.service"; /** * Consumer side of the payment microservice's outbox relay. - * Only the payment service may call this (shared SERVICE_AUTH_TOKEN). + * WARNING: currently unauthenticated — anyone who can reach the API can mark + * payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network. * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -@UseGuards(ServiceAuthGuard) +@Public() @Controller("internal/payments") export class InternalPaymentController { constructor(private readonly paymentService: PaymentService) { } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 330521218..05267746d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,8 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +// import { FirstMileModule } from "../first-mile/first-mile.module"; +// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, forwardRef(() => BillingModule), + // forwardRef(() => TrainSchedulingModule), + // FirstMileModule, TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 67ca68e87..3b86a940c 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum { } export class InitiatePaymentDto { - @ApiProperty({ example: "booking-uuid" }) + @ApiProperty({ example: "invoice-uuid" }) @IsString() - bookingId!: string; + invoiceId!: string; @ApiProperty({ enum: PaymentMethodTypeEnum, diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 45e737607..3f4d2e4ff 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -1,19 +1,31 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; + + @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) + @IsOptional() + @IsNumber() + @Min(0) + distanceKm?: number; } export class CreateRouteDto { - @ApiProperty() - @IsString() - @MaxLength(120) - name!: string; - @ApiProperty({ type: [CreateRouteMilestoneDto] }) @IsArray() @ArrayMinSize(2) @@ -21,8 +33,8 @@ export class CreateRouteDto { @Type(() => CreateRouteMilestoneDto) milestones!: CreateRouteMilestoneDto[]; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 020a34cdf..59188a2a6 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -1,16 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class FilterRoutesDto { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' }) @IsOptional() @IsString() search?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts index 63e37b8ec..c40baaa82 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity { @Column({ name: 'sequence_no', type: 'int' }) sequenceNo!: number; + + /** Kilometres from the previous stop (0 for origin). */ + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 8c6e4785e..9c25778dc 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Yard } from '../../rule-engine/entities/yard.entity'; import { RouteMilestone } from './route-milestone.entity'; -@Entity({ schema: 'freight', name: 'routes' }) -@Index(['name']) -@Index(['isActive']) -export class Route extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) - name!: string; +export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['status']) +export class Route extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; @@ -25,9 +23,24 @@ export class Route extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard; - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) + status!: RouteStatus; @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) milestones?: RouteMilestone[]; } + +export function formatRouteLabel(route: { + originYard?: { code?: string; name?: string } | null; + destinationYard?: { code?: string; name?: string } | null; +}): string { + const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; + const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + return `${origin} → ${dest}`; +} + +export function totalRouteDistanceKm( + milestones: Array<{ distanceKm?: number | string | null }>, +): number { + return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0); +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4c8e62498..937ffb0ae 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -1,12 +1,12 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, ILike } from 'typeorm'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { Route } from './entities/route.entity'; +import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; @Injectable() @@ -16,11 +16,10 @@ export class RoutesService { private readonly routesRepository: RoutesRepository, ) {} - findAll(filter: FilterRoutesDto): Promise { - return this.routesRepository.findAll({ + async findAll(filter: FilterRoutesDto): Promise { + const routes = await this.routesRepository.findAll({ where: { - ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), - ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + ...(filter.status ? { status: filter.status } : {}), }, relations: { originYard: true, @@ -28,10 +27,33 @@ export class RoutesService { milestones: { yard: true }, }, order: { - name: 'ASC', milestones: { sequenceNo: 'ASC' }, }, }); + + const sorted = [...routes].sort((a, b) => + formatRouteLabel(a).localeCompare(formatRouteLabel(b)), + ); + + const query = filter.search?.trim().toLowerCase(); + if (!query) return sorted; + + return sorted.filter((route) => { + const haystack = [ + formatRouteLabel(route), + route.originYard?.label, + route.originYard?.code, + route.destinationYard?.label, + route.destinationYard?.code, + ...(route.milestones ?? []).map( + (m) => m.yard?.label ?? m.yard?.code ?? m.yardId, + ), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); } async findById(id: string): Promise { @@ -53,16 +75,14 @@ export class RoutesService { } async create(dto: CreateRouteDto): Promise { - await this.validateRouteName(dto.name); const validated = await this.validateMilestones(dto.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: dto.name.trim(), originYardId: validated.originYardId, destinationYardId: validated.destinationYardId, - isActive: dto.isActive ?? true, + status: dto.status ?? 'AVAILABLE', }), ); @@ -72,6 +92,7 @@ export class RoutesService { routeId: savedRoute.id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -85,20 +106,16 @@ export class RoutesService { async update(id: string, dto: UpdateRouteDto): Promise { const existing = await this.findById(id); - if (dto.name && dto.name.trim() !== existing.name) { - await this.validateRouteName(dto.name, id); - } - const milestoneInput = dto.milestones ? await this.validateMilestones(dto.milestones) : null; await this.dataSource.transaction(async (manager) => { await manager.getRepository(Route).update(id, { - name: dto.name?.trim() ?? existing.name, originYardId: milestoneInput?.originYardId ?? existing.originYardId, - destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, - isActive: dto.isActive ?? existing.isActive, + destinationYardId: + milestoneInput?.destinationYardId ?? existing.destinationYardId, + ...(dto.status !== undefined ? { status: dto.status } : {}), }); if (milestoneInput) { @@ -109,6 +126,7 @@ export class RoutesService { routeId: id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -120,7 +138,9 @@ export class RoutesService { async deactivate(id: string): Promise { await this.findById(id); - const updated = await this.routesRepository.update(id, { isActive: false }); + const updated = await this.routesRepository.update(id, { + status: 'STOP_WORKING', + } as never); if (!updated) { throw new NotFoundException(`Route ${id} not found`); @@ -129,27 +149,32 @@ export class RoutesService { return this.findById(id); } - private async validateRouteName(name: string, routeId?: string) { - const trimmedName = name.trim(); - const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); - - if (existing && existing.id !== routeId) { - throw new ConflictException(`Route name ${trimmedName} already exists`); - } - } - - private async validateMilestones(milestones: Array<{ yardId: string }>) { + private async validateMilestones( + milestones: Array<{ yardId: string; distanceKm?: number }>, + ) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => ({ - yardId: milestone.yardId, - sequenceNo: index + 1, - })); + const normalized = milestones.map((milestone, index) => { + const distanceKm = + index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; + if (index > 0 && (distanceKm == null || distanceKm < 0)) { + throw new BadRequestException( + `Enter segment KM for stop ${index + 1} (from previous yard).`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm, + }; + }); const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; - const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); for (const milestone of normalized) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 794f97f83..f30ebd501 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -21,11 +21,6 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - showFreeTextBox?: boolean; - @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 2c1ed0e22..c8ac35f25 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -17,9 +17,6 @@ export class CargoType extends BaseEntity { @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) parentGroupId?: string | null; - @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) - showFreeTextBox!: boolean; - /** * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM * (break-bulk). Nullable for container/legacy cargo, which is counted by 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 16027f9c3..4b082e5fd 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 @@ -331,16 +331,14 @@ export class RuleEngineService { ): Promise { await this.ensureDefaultApprovalRules(); - let requiresDirectorApproval = options.freightType === 'BULK'; + let requiresDirectorApproval = false; if (options.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); if (!cargoType) { throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); } - if (cargoType.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType.requiresDirectorApproval; } const chain = await this.approvalRulesRepo.findChainForCargo( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 5a343f910..f80ada585 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -79,7 +79,6 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, - showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, 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 5cd06091a..9e974b31e 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 @@ -110,6 +110,7 @@ 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, ); }); 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 f31d8561d..218a20436 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 @@ -4,6 +4,7 @@ import { Logger, NotFoundException, OnModuleInit, + Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { Cron, SchedulerRegistry } from '@nestjs/schedule'; @@ -12,6 +13,7 @@ import { DataSource } from 'typeorm'; 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 { 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'; @@ -20,6 +22,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { Freight } from "@edr/types"; +import { BillingService } from "../billing/billing.service"; + + import { BATCH_CRON, BATCH_TIMEZONE, @@ -27,13 +33,14 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_WINDOW_MS, -} from './booking-batch.constants'; +} from "./booking-batch.constants"; import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -53,12 +60,12 @@ interface RouteDayGroup { type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = - | 'ALLOCATED' - | 'SELECTED_FOR_BATCH' - | 'READY' - | 'WAITING' - | 'PENDING_CONTRACT' - | 'EXPIRED'; + | "ALLOCATED" + | "SELECTED_FOR_BATCH" + | "READY" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; export interface BatchBoardBooking { id: string; @@ -73,10 +80,10 @@ export interface BatchBoardBooking { } export type BookingAllocationStatus = - | 'NOT_ATTEMPTED' - | 'ASSIGNED' - | 'DEFERRED' - | 'FAILED'; + | "NOT_ATTEMPTED" + | "ASSIGNED" + | "DEFERRED" + | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { fullyExecutedAt: string | null; @@ -114,9 +121,9 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; - locomotive: BatchBoardSchedule['locomotive']; - capacity: BatchBoardSchedule['capacity']; - counts: BatchBoardSchedule['counts']; + locomotive: BatchBoardSchedule["locomotive"]; + capacity: BatchBoardSchedule["capacity"]; + counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; @@ -179,6 +186,10 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, + private readonly billing: BillingService, + + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + ) {} /** On boot, reconcile OPEN route-days and re-arm settle timers. */ @@ -195,10 +206,10 @@ export class BookingBatchService implements OnModuleInit { } const reserved = await this.dataSource .getRepository(Booking) - .createQueryBuilder('b') - .select('DISTINCT b.train_schedule_id', 'scheduleId') + .createQueryBuilder("b") + .select("DISTINCT b.train_schedule_id", "scheduleId") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.train_schedule_id IS NOT NULL') + .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } @@ -276,7 +287,7 @@ export class BookingBatchService implements OnModuleInit { /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ private async openRouteDayGroups(): Promise { const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, + where: { bookingWindowStatus: "OPEN" }, }); const groups = new Map(); for (const s of open) { @@ -310,25 +321,29 @@ export class BookingBatchService implements OnModuleInit { if (!booking?.trainScheduleId) return; const isBatchPaid = - booking.status === 'SELECTED_FOR_BATCH' || - booking.status === 'AWAITING_PAYMENT' || - booking.status === 'PAID' || - booking.paymentStatus === 'PAID'; + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" || + booking.status === "PAID" || + booking.paymentStatus === "PAID"; if (!isBatchPaid) return; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); - } else if (booking.paymentStatus !== 'PAID') { + .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); + } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); + .update(bookingId, { paymentStatus: "PAID" }); } - const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + const linked = + await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { - await this.allocate(booking.trainScheduleId, booking, 'paid'); + await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); @@ -338,7 +353,7 @@ export class BookingBatchService implements OnModuleInit { booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -349,7 +364,11 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } - if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + if ( + result.issues.some( + (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", + ) + ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, @@ -364,9 +383,10 @@ export class BookingBatchService implements OnModuleInit { /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { - const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + const unlinked = + await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); @@ -375,7 +395,7 @@ export class BookingBatchService implements OnModuleInit { // ---- cron entry point ----------------------------------------------------- - @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -405,7 +425,7 @@ export class BookingBatchService implements OnModuleInit { destinationStation: true, route: true, }, - order: { scheduledDepartureDate: 'ASC' }, + order: { scheduledDepartureDate: "ASC" }, }); const wagonLengths = await this.loadWagonLengths(); @@ -413,7 +433,7 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -425,13 +445,15 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), }; }); @@ -442,11 +464,15 @@ export class BookingBatchService implements OnModuleInit { } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ - async getBatchBoardDetail(scheduleId: string): Promise { - const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { - throw new BadRequestException('Schedule is no longer active'); + async getBatchBoardDetail( + scheduleId: string, + ): Promise { + const s = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === "ARRIVED" || s.status === "CANCELLED") { + throw new BadRequestException("Schedule is no longer active"); } const wagonLengths = await this.loadWagonLengths(); @@ -456,12 +482,18 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< - ReturnType + ReturnType >; try { - allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + allocationPreview = + await this.trainSchedulingService.previewAllocationForSchedule(s.id); } catch { - allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + allocationPreview = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), @@ -474,17 +506,23 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), - fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, - selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, - allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + fullyExecutedAt: b.fullyExecutedAt + ? b.fullyExecutedAt.toISOString() + : null, + selectedForBatchAt: b.selectedForBatchAt + ? b.selectedForBatchAt.toISOString() + : null, + allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, }; }); @@ -514,11 +552,11 @@ export class BookingBatchService implements OnModuleInit { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { - if (b.state === 'ALLOCATED') counts.allocated += 1; - else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; - else if (b.state === 'READY') counts.ready += 1; - else if (b.state === 'WAITING') counts.waiting += 1; - else if (b.state === 'EXPIRED') counts.expired += 1; + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; @@ -526,7 +564,7 @@ export class BookingBatchService implements OnModuleInit { const windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { - if (key === 'pending-contract' || !bucket.window) continue; + if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, @@ -539,44 +577,51 @@ export class BookingBatchService implements OnModuleInit { bookings: bucket.items, }); } - windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + windows.sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); - const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, windows, pendingContract: { - key: 'pending-contract', - label: 'Pending contract', - date: '', - dateLabel: '', - start: '', - end: '', + key: "pending-contract", + label: "Pending contract", + date: "", + dateLabel: "", + start: "", + end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -597,17 +642,21 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, - ): BatchBoardSchedule['capacity'] { - const allocated = items.filter((i) => i.state === 'ALLOCATED'); + ): BatchBoardSchedule["capacity"] { + const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( - (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: - Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + Math.round( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, + ) / 100, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + usedWeightTons: + Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / + 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, }; } @@ -621,53 +670,68 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, capacity: this.computeBoardCapacity(items, loco), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } - private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { - if (linked) return 'ALLOCATED'; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { - return 'SELECTED_FOR_BATCH'; + private boardState( + booking: Booking, + linked: boolean, + ): BatchBoardBookingState { + if (linked) return "ALLOCATED"; + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { + return "SELECTED_FOR_BATCH"; } - if (booking.status === 'EXPIRED') return 'EXPIRED'; - if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; - if (booking.status === 'PAID') return 'WAITING'; - return 'PENDING_CONTRACT'; + if (booking.status === "EXPIRED") return "EXPIRED"; + if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) + return "READY"; + if (booking.status === "PAID") return "WAITING"; + return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${scheduleId} has no locomotive/train set — skipped.`, + ); return; } @@ -677,7 +741,7 @@ export class BookingBatchService implements OnModuleInit { await this.syncScheduleMaxWagons(schedule, locomotive, rules); let budget = await this.remainingCapacity(schedule, limits, wagonLengths); if (budget.wagons <= 0) { - await this.setWindow(scheduleId, 'FULL'); + await this.setWindow(scheduleId, "FULL"); return; } @@ -689,7 +753,12 @@ export class BookingBatchService implements OnModuleInit { if (!this.fits(need, budget)) { if (booking.isGovernment) { - budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); + budget = await this.preemptForGovernment( + scheduleId, + need, + budget, + wagonLengths, + ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { continue; // skip a booking that exceeds weight/length/wagons, try the next @@ -697,7 +766,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(scheduleId, booking, 'gov'); + await this.allocate(scheduleId, booking, "gov"); } else { await this.reserve(booking, scheduleId); armed = true; @@ -706,7 +775,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -732,13 +801,14 @@ export class BookingBatchService implements OnModuleInit { const scheduleIds = bookable .filter( (s) => - s.bookingWindowStatus === 'OPEN' && + s.bookingWindowStatus === "OPEN" && s.scheduleDate != null && eatDay(new Date(s.scheduleDate)) === day, ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + new Date(a.scheduleDate).getTime() - + new Date(b.scheduleDate).getTime(), ) .map((s) => s.id); @@ -750,15 +820,22 @@ export class BookingBatchService implements OnModuleInit { // Live per-schedule budget + arm flag, in departure order. const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; for (const id of scheduleIds) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${id} has no locomotive/train set — skipped.`, + ); continue; } 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.remainingCapacity( + schedule, + limits, + wagonLengths, + ); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; @@ -779,7 +856,12 @@ export class BookingBatchService implements OnModuleInit { // Government booking fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { - t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + t.budget = await this.preemptForGovernment( + t.id, + need, + t.budget, + wagonLengths, + ); if (this.fits(need, t.budget)) { target = t; break; @@ -794,7 +876,7 @@ export class BookingBatchService implements OnModuleInit { } if (booking.isGovernment) { - await this.allocate(target.id, booking, 'gov'); + await this.allocate(target.id, booking, "gov"); } else { await this.reserve(booking, target.id); target.armed = true; @@ -803,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { } for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -813,18 +895,20 @@ export class BookingBatchService implements OnModuleInit { /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); let anySettled = false; for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : false; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); anySettled = true; } else if (expired) { await this.expire(booking); @@ -840,17 +924,19 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const paid = + booking.paymentStatus === "PAID" || booking.status === "PAID"; const expired = booking.paymentDeadline ? booking.paymentDeadline.getTime() <= now : true; if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); } else if (expired) { await this.expire(booking); } @@ -862,11 +948,13 @@ export class BookingBatchService implements OnModuleInit { } private triggerWagonAllocation(scheduleId: string): void { - void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => - this.logger.warn( - `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, - ), - ); + void this.trainSchedulingService + .tryAutoWagonAllocation(scheduleId) + .catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); } // ---- staff override actions ---------------------------------------------- @@ -878,18 +966,20 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { - throw new BadRequestException('Booking has no target schedule to allocate to'); + throw new BadRequestException( + "Booking has no target schedule to allocate to", + ); } await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); - await this.allocate(booking.trainScheduleId, booking, 'paid'); + .update(bookingId, { paymentStatus: "PAID" }); + await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } @@ -898,7 +988,10 @@ export class BookingBatchService implements OnModuleInit { * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ - async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + async moveToSchedule( + bookingId: string, + newScheduleId: string, + ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); @@ -907,15 +1000,20 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); - if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); - if (schedule.bookingWindowStatus !== 'OPEN') { - throw new BadRequestException('Target schedule is not accepting bookings'); + if (!schedule) + throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== "OPEN") { + throw new BadRequestException( + "Target schedule is not accepting bookings", + ); } if ( schedule.originStationId !== booking.originYardId || schedule.destinationStationId !== booking.destinationYardId ) { - throw new BadRequestException('Target schedule is not on the booking route'); + throw new BadRequestException( + "Target schedule is not on the booking route", + ); } await this.dataSource.transaction(async (manager) => { @@ -927,15 +1025,15 @@ export class BookingBatchService implements OnModuleInit { ); } const restoredStatus = - booking.status === 'EXPIRED' + booking.status === "EXPIRED" ? booking.isGovernment - ? 'APPROVED' - : 'FULLY_EXECUTED' + ? "APPROVED" + : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: 'ELIGIBLE', + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -949,7 +1047,8 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) + await this.fillSchedule(booking.trainScheduleId); } // ---- mutations ------------------------------------------------------------ @@ -967,11 +1066,19 @@ export class BookingBatchService implements OnModuleInit { const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, - status: 'SELECTED_FOR_BATCH', + status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; + // The invoice was generated at booking creation/approval, before this pay + // window opened — refresh its printed due date to the real deadline. + await this.billing.syncPayableDueDate( + Freight.InvoiceSource.Booking, + booking.id, + deadline, + "PREPAID", + ); await this.notifier.payNow(booking, deadline); } @@ -979,13 +1086,14 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: 'paid' | 'gov', + reason: "paid" | "gov", ): Promise { await this.dataSource.transaction(async (manager) => { - const exists = await this.trainScheduleBookingsRepository.existsForBooking( - booking.id, - manager, - ); + const exists = + await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], @@ -993,8 +1101,8 @@ export class BookingBatchService implements OnModuleInit { ); } await manager.getRepository(Booking).update(booking.id, { - status: reason === 'paid' ? 'PAID' : booking.status, - schedulingStatus: 'SCHEDULED', + status: reason === "paid" ? "PAID" : booking.status, + schedulingStatus: "SCHEDULED", scheduledAt: new Date(), paymentDeadline: null, selectedForBatchAt: null, @@ -1002,6 +1110,16 @@ export class BookingBatchService implements OnModuleInit { }); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); + void this.markWagonAllocatedMilestone(booking.id); + } + + private async markWagonAllocatedMilestone(bookingId: string): Promise { + if (!this.milestoneService) return; + try { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } catch { + // Booking may have no milestone rows (non-contract path). + } } /** @@ -1012,12 +1130,16 @@ export class BookingBatchService implements OnModuleInit { private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { trainScheduleId: null, - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // Pay window closed before settlement → expire the booking's open invoice too + // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays + // source-agnostic. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); } @@ -1035,7 +1157,9 @@ export class BookingBatchService implements OnModuleInit { await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = - await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + await this.bookingsRepository.findAllocatedCommercialForSchedule( + scheduleId, + ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( @@ -1052,11 +1176,19 @@ export class BookingBatchService implements OnModuleInit { manager, ); await manager.getRepository(Booking).update(victim.id, { - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); + // Displaced → EXPIRED: close its open invoice too, so a dead booking + // can't still be paid (mirrors `expire()`; enlisted in this txn). + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + victim.id, + "PREPAID", + manager, + ); }); this.notifier.displaced(victim); freed = this.add(freed, this.needFor(victim, wagonLengths)); @@ -1074,7 +1206,10 @@ export class BookingBatchService implements OnModuleInit { (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + return Math.max( + DEFAULT_WAGONS_PER_BOOKING, + fromContainers || DEFAULT_WAGONS_PER_BOOKING, + ); } /** What one booking consumes along all three capacity axes. */ @@ -1161,7 +1296,7 @@ export class BookingBatchService implements OnModuleInit { Array<{ lengthMeters: number; capacityTons: number }> > { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ @@ -1172,17 +1307,23 @@ export class BookingBatchService implements OnModuleInit { private async loadWagonLengths(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); - const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + const byCode = new Map( + types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), + ); return { - container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: + byCode.get("NW5")?.lengthMeters ?? + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, }; } private async loadGlobalRules(): Promise { - return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + return this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .findOne({ where: {} }); } /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ @@ -1194,7 +1335,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + 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 }, @@ -1207,7 +1350,9 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, + ); const used = allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); @@ -1216,7 +1361,7 @@ export class BookingBatchService implements OnModuleInit { private async setWindow( scheduleId: string, - status: 'OPEN' | 'FULL' | 'CLOSED', + status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) @@ -1233,7 +1378,9 @@ export class BookingBatchService implements OnModuleInit { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => - this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), ); }, PAYMENT_WINDOW_MS); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); @@ -1242,7 +1389,7 @@ export class BookingBatchService implements OnModuleInit { private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { - if (this.scheduler.doesExist('timeout', name)) { + if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts index 9bd9f3957..d5adce129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator'; export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'GATE_PASS', 'DELIVERY_ORDER', 'PORT_INVOICE', 'DJIBOUTI_T1', @@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto { } export class ImportDjiboutiActionDto { + @ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' }) + @IsOptional() + @IsDateString() + securedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts index 792792070..47ced8738 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; export type ImportDjiboutiDocumentType = + | 'GATE_PASS' | 'DELIVERY_ORDER' | 'PORT_INVOICE' | 'DJIBOUTI_T1' 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 9b8efd9e0..0f2e22076 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 @@ -1,6 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -25,6 +26,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ @@ -42,6 +44,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), + BillingModule, NotificationsModule, LocomotivesModule, WagonTypesModule, @@ -49,6 +52,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; TrainSchedulesModule, forwardRef(() => WarehousesModule), RuleEngineModule, + forwardRef(() => ContractsModule), ], controllers: [TrainSchedulingController], providers: [ 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 c71634ccb..e87dbdd88 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 @@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { Route } from '../routes/entities/route.entity'; +import { formatRouteLabel, Route } from '../routes/entities/route.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'; @@ -54,7 +54,6 @@ import { type ImportDjiboutiDocumentType, } from './entities/import-djibouti-operation.entity'; import { - IMPORT_DJIBOUTI_DOCUMENT_TYPES, ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; @@ -304,7 +303,7 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const route = await this.getActiveRoute(dto.routeId); + const route = await this.getSchedulableRoute(dto.routeId); const locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 2) { @@ -832,7 +831,7 @@ export class TrainSchedulingService { } async getImportDjiboutiOperation(scheduleId: string) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); return this.mapImportDjiboutiOperation(schedule, operation); } @@ -841,7 +840,7 @@ export class TrainSchedulingService { scheduleId: string, dto: UploadImportDjiboutiDocumentDto, ) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const documents = { ...(operation.documents ?? {}), @@ -865,21 +864,30 @@ export class TrainSchedulingService { } async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); - const missing = this.missingImportDjiboutiDocuments(operation); - if (missing.length) { - throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date(); + const documents = { ...(operation.documents ?? {}) }; + if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) { + documents.GATE_PASS = { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }; } await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { - gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + documents, + gatepassGrantedAt: securedAt, performedBy: dto.performedBy ?? operation.performedBy ?? null, notes: dto.notes ?? operation.notes ?? null, }); console.log( - `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } @@ -949,7 +957,7 @@ export class TrainSchedulingService { generatedAt: generatedAt.toISOString(), trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, - route: schedule.route?.name ?? null, + route: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, totalBookings: schedule.scheduleBookings?.length ?? 0, @@ -1299,16 +1307,28 @@ export class TrainSchedulingService { } private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private async getDjiboutiGatepassSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!this.isImportDjiboutiSchedule(schedule)) { - throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + if (!this.isDjiboutiGatepassSchedule(schedule)) { + throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes'); } return schedule; } + private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean { + return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule); + } + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { const direction = (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? @@ -1323,6 +1343,20 @@ export class TrainSchedulingService { ); } + private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'EXPORT' && + this.isDjiboutiPortDestination( + `${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`, + ) + ); + } + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { const repo = this.dataSource.getRepository(ImportDjiboutiOperation); const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); @@ -1331,8 +1365,8 @@ export class TrainSchedulingService { } private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { - const documents = operation?.documents ?? {}; - return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + void operation; + return []; } private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { @@ -1343,6 +1377,7 @@ export class TrainSchedulingService { private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { const missingDocuments = this.missingImportDjiboutiDocuments(operation); + const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED'; return { trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, @@ -1350,6 +1385,7 @@ export class TrainSchedulingService { status: { documentsComplete: missingDocuments.length === 0, missingDocuments, + gatepassStatus, gatepassGranted: Boolean(operation.gatepassGrantedAt), readyForLoading: Boolean(operation.readyForLoadingAt), loadedOnTrain: Boolean(operation.loadedOnTrainAt), @@ -1358,6 +1394,8 @@ export class TrainSchedulingService { }, documents: operation.documents ?? {}, gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + gatepassSecuredAt: operation.gatepassGrantedAt ?? null, + gatepassStatus, readyForLoadingAt: operation.readyForLoadingAt ?? null, loadedOnTrainAt: operation.loadedOnTrainAt ?? null, departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, @@ -2605,13 +2643,17 @@ export class TrainSchedulingService { return saved; } - private async getActiveRoute(routeId: string) { + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, relations: { originYard: true, destinationYard: true }, }); if (!route) throw new NotFoundException(`Route ${routeId} not found`); - if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); + if (route.status !== 'AVAILABLE') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, + ); + } return route; } @@ -2656,7 +2698,7 @@ export class TrainSchedulingService { id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, - routeName: schedule.route?.name ?? null, + routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, @@ -2691,7 +2733,7 @@ export class TrainSchedulingService { /** AVAILABLE locomotives at the route's origin yard. */ async getAvailableLocomotivesForRoute(routeId: string): Promise { - const route = await this.getActiveRoute(routeId); + const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ where: { status: 'AVAILABLE', currentYardId: route.originYardId }, @@ -2933,7 +2975,9 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, - route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + route: schedule.route + ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, actualDepartureAt: schedule.actualDepartureAt ?? null, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 5e31c8593..8dd0681ce 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -90,6 +90,11 @@ export class TruckEntranceDto { @Min(0) grossWeightKg?: number; + @ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' }) + @IsOptional() + @IsBoolean() + weighingRequired?: boolean; + @ApiPropertyOptional() @IsOptional() @IsNumber() @@ -135,10 +140,11 @@ export class TruckEntranceDto { @IsString() truckType?: string; - @ApiProperty() + @ApiPropertyOptional() + @IsOptional() @IsNumber() @Min(0) - entranceTareWeightKg!: number; + entranceTareWeightKg?: number; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 873f97a6b..e2c20901d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,8 +1,27 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; +export class FeeRuleTierDto { + @ApiProperty({ example: 4 }) + @IsInt() + @Min(1) + fromDay!: number; + + @ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' }) + @IsOptional() + @IsInt() + @Min(1) + toDay?: number | null; + + @ApiProperty({ example: 2500 }) + @IsNumber() + @Min(0) + ratePerDay!: number; +} + export class CreateFeeRuleDto { @ApiProperty() @IsString() @@ -67,6 +86,13 @@ export class CreateFeeRuleDto { @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ type: [FeeRuleTierDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FeeRuleTierDto) + tiers?: FeeRuleTierDto[]; + @ApiPropertyOptional({ default: 'USD' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index f346be282..38dffd235 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm'; export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; +export interface WarehouseFeeTier { + fromDay: number; + toDay: number | null; + ratePerDay: number; +} + /** * Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6). * The most specific active rule (highest `specificity` then lowest `priority`) applies to an item. @@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) ratePerDay!: number; + @Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" }) + tiers!: WarehouseFeeTier[]; + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) currency!: string; 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 c1faeed22..5fcf59dd5 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 @@ -35,6 +35,7 @@ export interface ImportTrainItemRow { wagonNumber: string | null; sequenceNo: number | null; allocatedWeightTons: number | null; + freightType: string | null; containerNumber: string | null; cargoType: string | null; weight: number | null; @@ -274,6 +275,7 @@ export class SchedulingReadFacade { w.wagon_number AS "wagonNumber", tsw.sequence_no AS "sequenceNo", wba.allocated_weight_tons AS "allocatedWeightTons", + b.freight_type AS "freightType", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index e0a0f2b6c..cfc7fbf6c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,9 +1,9 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; -import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { @@ -39,6 +39,15 @@ export interface FeePreview { containerCount: number; billableUnits: number; amount: number; + tiers: Array<{ + fromDay: number; + toDay: number | null; + appliedFromDay: number; + appliedToDay: number; + days: number; + ratePerDay: number; + amount: number; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -57,11 +66,16 @@ export class WarehouseFeeService { } createRule(dto: CreateFeeRuleDto): Promise { - return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + return this.feeRuleRepository.create({ + isActive: true, + priority: 100, + currency: 'USD', + ...this.normalizeRuleInput(dto), + }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { - const updated = await this.feeRuleRepository.update(id, dto); + const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto)); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); return updated; } @@ -70,6 +84,40 @@ export class WarehouseFeeService { return this.feeRuleRepository.softDelete(id); } + private normalizeRuleInput(dto: T): T { + if (dto.tiers === undefined) return dto; + const tiers = (dto.tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0); + + for (const tier of tiers) { + if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) { + throw new BadRequestException('Fee tier from day must be a positive whole number.'); + } + if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) { + throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.'); + } + if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) { + throw new BadRequestException('Fee tier rate per day must be zero or greater.'); + } + } + + const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity)); + for (let i = 1; i < sorted.length; i += 1) { + const prev = sorted[i - 1]; + const current = sorted[i]; + if (prev.toDay == null || current.fromDay <= prev.toDay) { + throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.'); + } + } + + return { ...dto, tiers: sorted } as T; + } + private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", @@ -82,16 +130,27 @@ export class WarehouseFeeService { w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode", - ctt.code AS "containerTypeCode", + COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", + COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT bc.container_type_id + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + AND bc.container_type_id IS NOT NULL + ORDER BY bc.created_at ASC + LIMIT 1 + ) booking_container_type ON true + LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count FROM freight.booking_container bc @@ -108,16 +167,26 @@ export class WarehouseFeeService { private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { // Returns specificity score (#matched non-null scope fields), or null if any constraint fails. let score = 0; - const check = (ruleVal: string | null | undefined, itemVal: string | null) => { - if (ruleVal == null) return true; - if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) { + const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null; + const check = ( + ruleVal: string | null | undefined, + itemVal: string | null, + opts: { allowBoth?: boolean } = {}, + ) => { + const ruleCode = normalized(ruleVal); + if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true; + if (opts.allowBoth && ruleCode === 'BOTH') { + score += 1; + return true; + } + if (ruleCode === normalized(itemVal)) { score += 1; return true; } return false; }; if (!check(rule.freightType, item.freightType)) return null; - if (!check(rule.tradeDirection, item.tradeDirection)) return null; + if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; if (!check(rule.facilityId, item.facilityId)) return null; @@ -153,6 +222,60 @@ export class WarehouseFeeService { return Math.round(amount * rate * 100) / 100; } + private calculateTieredAmount( + tiers: WarehouseFeeTier[] | null | undefined, + elapsedDays: number, + containerCount: number, + ): { + sourceAmount: number; + billableUnits: number; + chargeableDays: number; + weightedRatePerDay: number; + tiers: FeePreview['tiers']; + } { + const sourceTiers = (tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay)) + .sort((a, b) => a.fromDay - b.fromDay); + + let sourceAmount = 0; + let tierDays = 0; + const appliedTiers: FeePreview['tiers'] = []; + + for (const tier of sourceTiers) { + if (elapsedDays < tier.fromDay) continue; + const appliedFromDay = tier.fromDay; + const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays); + const days = Math.max(0, appliedToDay - appliedFromDay + 1); + if (days <= 0) continue; + + const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100; + sourceAmount += amount; + tierDays += days; + appliedTiers.push({ + fromDay: tier.fromDay, + toDay: tier.toDay, + appliedFromDay, + appliedToDay, + days, + ratePerDay: tier.ratePerDay, + amount, + }); + } + + return { + sourceAmount: Math.round(sourceAmount * 100) / 100, + billableUnits: tierDays * containerCount, + chargeableDays: tierDays, + weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0, + tiers: appliedTiers, + }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -176,13 +299,25 @@ export class WarehouseFeeService { const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; - const chargeableDays = Math.max(0, elapsedDays - freeDays); - const billableUnits = chargeableDays * containerCount; - const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100; + const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount); + const hasTiers = Boolean(rule?.tiers?.length); + const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; const convertedRatePerDay = ruleCurrency - ? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency) + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; return { ruleType, @@ -201,6 +336,7 @@ export class WarehouseFeeService { containerCount, billableUnits, amount, + tiers: hasTiers ? convertedTiers : [], }; } 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 1de6daf81..ff25258d3 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 @@ -84,9 +84,11 @@ export class WarehouseInspectionService { `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", b.trade_direction AS "tradeDirection", - b.last_mile_delivery_address AS "lastMileDeliveryAddress" + b.last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -98,7 +100,10 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - if (row.bookingReference && row.lastMileDeliveryAddress) { + const hasLastMile = + Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + + if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); } } 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 6b2bd8c28..6b30dad1e 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 @@ -139,8 +139,18 @@ export class WarehouseInventoryController { @Post('import/auto-unload-arrived-bookings') @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) - autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { - return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + autoUnloadArrivedBookings(@Body() dto: { + scheduleId: string; + warehouseId?: string; + performedBy?: string; + assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; + }) { + return this.inventoryService.autoUnloadArrivedBookings( + dto.scheduleId, + dto.performedBy, + dto.warehouseId, + dto.assignments, + ); } @Get('import/unloaded-queue') 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 001897b3f..11f34c892 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 @@ -180,6 +180,10 @@ interface LocationRef { zoneId: string; } +interface BookingUnloadLocation extends LocationRef { + bookingId: string; +} + interface LocationNode { capacityWeight?: number | null; capacityContainers?: number | null; @@ -221,6 +225,11 @@ export interface EligibleBookingRow { firstMileDriverPhone: string | null; firstMileDriverLicenseNumber: string | null; firstMileTruckType: string | null; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; } export interface BulkReceiveResult { @@ -302,6 +311,11 @@ export interface ImportUnloadedRow { inspectionStatus: string | null; pickupOption: string; lastMileRequested: boolean; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -504,8 +518,9 @@ export class WarehouseInventoryService { })); } - /** First warehouse that has at least one yard + zone (fallback location for auto-unload). */ - private async pickDefaultLocation(): Promise { + /** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(warehouseId?: string): Promise { + const params = warehouseId ? [warehouseId] : []; const [row]: DefaultLocation[] = await this.dataSource.query( `SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId", yard.id AS "yardId", zone.id AS "zoneId" @@ -513,8 +528,10 @@ export class WarehouseInventoryService { JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL WHERE wh.deleted_at IS NULL + ${warehouseId ? 'AND wh.id = $1' : ''} ORDER BY wh.created_at ASC - LIMIT 1`, + LIMIT 1`, + params, ); return row ?? null; } @@ -592,6 +609,7 @@ export class WarehouseInventoryService { dto.warehouseId && dto.yardId && dto.zoneId ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null } : null; + if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId); if (!location) location = await this.pickDefaultLocation(); if (!location) { throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading'); @@ -704,7 +722,12 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + 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" 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 @@ -796,7 +819,12 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + 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" 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 @@ -1041,9 +1069,16 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", - CASE WHEN b.last_mile_delivery_address IS NOT NULL + CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + 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", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", @@ -1056,6 +1091,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -1157,6 +1193,8 @@ export class WarehouseInventoryService { async autoUnloadArrivedBookings( scheduleId: string, performedBy?: string, + warehouseId?: string, + assignments: BookingUnloadLocation[] = [], ): Promise { const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; @@ -1203,7 +1241,21 @@ export class WarehouseInventoryService { [scheduleId], ); - const fallback = await this.pickDefaultLocation(); + const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; + if (warehouseId && !requestedLocation) { + throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading'); + } + const fallback = requestedLocation ?? (await this.pickDefaultLocation()); + const assignmentByBooking = new Map( + assignments.map((assignment) => [ + assignment.bookingId, + { + warehouseId: assignment.warehouseId, + yardId: assignment.yardId, + zoneId: assignment.zoneId, + } satisfies LocationRef, + ]), + ); const now = new Date(); for (const booking of bookings) { @@ -1223,6 +1275,8 @@ export class WarehouseInventoryService { try { const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + const assignedLocation = assignmentByBooking.get(booking.id) ?? null; + const unloadLocation = assignedLocation ?? requestedLocation; // Already unloaded or further along — leave it (do not regress the lifecycle). if (existing && existing.status !== 'RECEIVED') { @@ -1232,6 +1286,13 @@ export class WarehouseInventoryService { if (existing) { await this.inventoryRepository.update(existing.id, { + ...(unloadLocation + ? { + warehouseId: unloadLocation.warehouseId, + yardId: unloadLocation.yardId, + zoneId: unloadLocation.zoneId, + } + : {}), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, @@ -1239,7 +1300,7 @@ export class WarehouseInventoryService { await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', inventoryId: existing.id, - warehouseId: existing.warehouseId, + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, description: 'Unloaded from arrived import train', performedBy, }); @@ -1254,7 +1315,7 @@ export class WarehouseInventoryService { tradeDirection: booking.tradeDirection, cargoTypeCode: booking.cargoTypeCode, }); - const location = allocated ?? fallback; + const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback; if (!location) { fail('No warehouse/yard/zone configured'); continue; @@ -1336,6 +1397,16 @@ export class WarehouseInventoryService { if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); } + const [gatepass] = await this.dataSource.query( + `SELECT gatepass_granted_at AS "gatepassSecuredAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!gatepass?.gatepassSecuredAt) { + throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED'); + } const items: Array<{ bookingId: string; @@ -1631,13 +1702,17 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL + last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + const hasLastMile = + Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } @@ -1955,11 +2030,19 @@ export class WarehouseInventoryService { } const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + if (isTruckLeaving) { + await this.invoices.assertClearanceAllowed(id); + } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionNote = this.buildExitInspectionNote(dto); + const reference = isTruckLeaving + ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) + : dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = isTruckLeaving + ? this.preserveTruckArrivalForExit(dto, item.notes) + : dto; + const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { @@ -1967,6 +2050,17 @@ export class WarehouseInventoryService { releaseOrderReference: reference, notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); + if (!isTruckLeaving && item.bookingId) { + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 + AND customer_truck_assigned_at IS NOT NULL + AND deleted_at IS NULL`, + [item.bookingId], + ); + } await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', @@ -2034,6 +2128,7 @@ export class WarehouseInventoryService { if (!row.releaseDate) { throw new BadRequestException('A release order must be issued before downloading the exit paper'); } + await this.invoices.assertClearanceAllowed(id); const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -2180,11 +2275,19 @@ export class WarehouseInventoryService { throw new BadRequestException('Please save your signature before approving delivery'); } - const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = + const [item]: Array<{ + id: string; + warehouseId: string | null; + notes: string | null; + customerTruckAssignedAt: string | null; + customerTruckArrivedAt: string | null; + }> = await this.dataSource.query( `SELECT inv.id, inv.warehouse_id AS "warehouseId", - inv.notes + inv.notes, + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.customer_truck_arrived_at AS "customerTruckArrivedAt" FROM freight.warehouse_inventory inv JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL WHERE inv.booking_id = $1 @@ -2198,6 +2301,10 @@ export class WarehouseInventoryService { if (!item) { throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); } + if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) { + throw new BadRequestException('Customer truck arrival must be recorded before delivery approval'); + } + await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); const approval = { @@ -2301,6 +2408,7 @@ export class WarehouseInventoryService { if (!row) { throw new NotFoundException(`Inventory item ${id} not found`); } + await this.invoices.assertClearanceAllowed(id); if (row.inspectionStatus !== 'PASSED') { throw new BadRequestException('Handover document is available after inspection has passed'); } @@ -3302,8 +3410,13 @@ export class WarehouseInventoryService { if (!truckEntrance.driverPhone?.trim()) { throw new BadRequestException('Driver phone is required for entrance registration'); } - if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { - throw new BadRequestException('Entrance tare weight is required for entrance registration'); + if (truckEntrance.weighingRequired) { + if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) { + throw new BadRequestException('Gross weight is required when customer truck weighing is Yes'); + } + if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) { + throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes'); + } } } @@ -3325,6 +3438,11 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; + customerTruckAssignedAt?: string | null; }, ): TruckEntranceDto { return { @@ -3340,16 +3458,22 @@ export class WarehouseInventoryService { booking.containerQuantity !== undefined && booking.containerQuantity !== null ? Number(booking.containerQuantity) : submitted.unitCount, - grossWeightKg: - booking.weight !== undefined && booking.weight !== null - ? Number(booking.weight) - : submitted.grossWeightKg, - truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + grossWeightKg: submitted.grossWeightKg, + truckPlateNumber: + booking.firstMileTruckPlateNumber?.trim() || + booking.customerTruckPlateNumber?.trim() || + submitted.truckPlateNumber, trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, - driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverName: + booking.firstMileDriverName?.trim() || + booking.customerTruckDriverName?.trim() || + submitted.driverName, driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, - truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + truckType: + booking.firstMileTruckType?.trim() || + booking.customerTruckType?.trim() || + submitted.truckType, }; } @@ -3543,6 +3667,24 @@ export class WarehouseInventoryService { return rows.filter(Boolean).join('\n'); } + private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { + const inspection = this.extractExitInspectionNote(notes); + if (!inspection) return dto; + + return { + ...dto, + truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, + driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, + driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, + driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone, + truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType, + containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, + gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, + tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + }; + } + private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { const trimmed = notes?.trim(); if (!exitInspectionNote) return trimmed || null; @@ -3564,6 +3706,18 @@ export class WarehouseInventoryService { return notes.slice(index + marker.length).trim() || null; } + private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { + const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im')); + return match?.[1]?.trim() || null; + } + + private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, ''); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + private extractReceiveSummary(notes?: string | null): string | null { if (!notes?.trim()) return null; const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes; @@ -3622,6 +3776,7 @@ export class WarehouseInventoryService { 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?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a66cf91d0..8b16e3bec 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -86,4 +87,10 @@ export class WarehouseInvoiceController { pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); } + + @Post('warehouse-fee-invoices/:id/pay-online') + @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) + payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { + return this.invoiceService.initiatePayment(id, dto); + } } 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 6f7219781..f40153dad 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 @@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; +import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; @@ -171,8 +172,12 @@ export class WarehouseInvoiceService { feeType, description: p.ruleType === 'STORAGE_FEE' - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ + p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` + }` + : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ + p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` + }`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -305,6 +310,22 @@ export class WarehouseInvoiceService { return detail; } + /** Initiate a wallet/gateway payment for the invoice. */ + async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('Invoice is already fully paid.'); + } + + return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, { + method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'), + platform: dto.platform ?? 'web', + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + /** * Notify on online (gateway) settlement — the domain side-effect of a warehouse * fee being paid through billing's payment flow. The counter {@link pay} path diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts new file mode 100644 index 000000000..f3a304233 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -0,0 +1,35 @@ +import "reflect-metadata"; +import { config } from "dotenv"; + +config(); + +import Vorpal from "vorpal"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; + +const vorpal = new Vorpal(); + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + try { + // registerCommands(vorpal, { app }); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Script failed:", err); + process.exit(1); +}); 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 new file mode 100644 index 000000000..a57ce84c7 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -0,0 +1,627 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; +import { In } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { AppDataSource } from '../data-source'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile, ProfileStatus, ProfileType } from '../modules/companies/entities/company-profile.entity'; +import { Company, CompanyKind, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { Container } from '../modules/container-management/entities/container.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +type Direction = 'IMPORT' | 'EXPORT'; +type TrainStatus = 'SCHEDULED' | 'ARRIVED'; + +interface ScenarioTrain { + trainNumber: string; + direction: Direction; + status: TrainStatus; + departureOffsetHours: number; + arrivalOffsetHours: number; + bookings: Array<{ + reference: string; + mileVariant: 'FIRST_MILE' | 'LAST_MILE' | 'TERMINAL'; + containerNumber: string; + weightTons: number; + }>; +} + +const SCENARIOS: ScenarioTrain[] = [ + { + trainNumber: 'GP-IMP-ARR-01', + direction: 'IMPORT', + status: 'ARRIVED', + departureOffsetHours: -18, + arrivalOffsetHours: -6, + bookings: [ + { reference: 'GP-IMP-ARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000001', weightTons: 22 }, + { reference: 'GP-IMP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000002', weightTons: 24 }, + ], + }, + { + trainNumber: 'GP-IMP-NARR-01', + direction: 'IMPORT', + status: 'SCHEDULED', + departureOffsetHours: 6, + arrivalOffsetHours: 18, + bookings: [ + { reference: 'GP-IMP-NARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000003', weightTons: 21 }, + { reference: 'GP-IMP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000004', weightTons: 23 }, + ], + }, + { + trainNumber: 'GP-EXP-ARR-01', + direction: 'EXPORT', + status: 'ARRIVED', + departureOffsetHours: -16, + arrivalOffsetHours: -4, + bookings: [ + { reference: 'GP-EXP-ARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000001', weightTons: 20 }, + { reference: 'GP-EXP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000002', weightTons: 22 }, + ], + }, + { + trainNumber: 'GP-EXP-NARR-01', + direction: 'EXPORT', + status: 'SCHEDULED', + departureOffsetHours: 8, + arrivalOffsetHours: 20, + bookings: [ + { reference: 'GP-EXP-NARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000003', weightTons: 19 }, + { reference: 'GP-EXP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000004', weightTons: 21 }, + ], + }, +]; + +const addHours = (date: Date, hours: number): Date => new Date(date.getTime() + hours * 60 * 60 * 1000); + +async function main() { + const dataSource = await AppDataSource.initialize(); + + try { + const seeded = await dataSource.transaction(async (manager) => { + if (await isAlreadySeeded(manager)) { + return null; + } + const refs = await ensureReferences(manager); + const now = new Date(); + const result: Array<{ trainNumber: string; bookings: string[] }> = []; + + for (const scenario of SCENARIOS) { + const schedule = await seedScenarioTrain(manager, scenario, refs, now); + result.push({ + trainNumber: schedule.trainNumber ?? scenario.trainNumber, + bookings: scenario.bookings.map((booking) => booking.reference), + }); + } + + return result; + }); + + console.log('Gate-pass train scenario seed complete.'); + if (seeded) { + for (const row of seeded) { + console.log(`${row.trainNumber}: ${row.bookings.join(', ')}`); + } + } else { + console.log('Gate-pass train scenarios already seeded; nothing changed.'); + } + } finally { + await dataSource.destroy(); + } +} + +async function isAlreadySeeded(manager: any): Promise { + const scheduleRepo = manager.getRepository(TrainSchedule); + const bookingRepo = manager.getRepository(Booking); + const trainNumbers = SCENARIOS.map((scenario) => scenario.trainNumber); + const bookingRefs = SCENARIOS.flatMap((scenario) => scenario.bookings.map((booking) => booking.reference)); + + const [scheduleCount, bookingCount] = await Promise.all([ + scheduleRepo.count({ where: { trainNumber: In(trainNumbers) } }), + bookingRepo.count({ where: { reference: In(bookingRefs) } }), + ]); + + return scheduleCount === trainNumbers.length && bookingCount === bookingRefs.length; +} + +async function ensureReferences(manager: any) { + const yardRepo = manager.getRepository(Yard); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const wagonTypeRepo = manager.getRepository(WagonType); + const companyRepo = manager.getRepository(Company); + const profileRepo = manager.getRepository(CompanyProfile); + const warehouseRepo = manager.getRepository(Warehouse); + const warehouseYardRepo = manager.getRepository(WarehouseYard); + const warehouseZoneRepo = manager.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'NAGAD', + label: 'Nagad Port', + country: 'Djibouti', + isActive: true, + displayOrder: 90, + }), + )); + + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 91, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for gate-pass scenario seed', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.findOne({ where: { isActive: true } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }), + )); + + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'GP-FLAT' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.findOne({ where: { isActive: true } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'GP-FLAT', + name: 'Gate Pass Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'GTPASS001' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Gate Pass Scenario Customer', + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: 'GTPASS001', + vatNumber: 'GTPASS001', + fanNumber: 'GTPASS0000001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000555', + email: 'gate-pass-scenarios@edr.local', + contactPersonName: 'Gate Pass Tester', + contactPersonPhone: '251900000555', + }), + )); + + const importerProfile = await ensureProfile(profileRepo, company.id, ProfileType.importer, 'GP-IMP'); + const exporterProfile = await ensureProfile(profileRepo, company.id, ProfileType.exporter, 'GP-EXP'); + + const warehouse = + (await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } })) ?? + (await warehouseRepo.findOne({ where: {} })); + if (!warehouse) { + throw new Error('No warehouse found. Run the Indode/warehouse seed before gate-pass scenarios.'); + } + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + throw new Error(`No warehouse yard found for ${warehouse.code ?? warehouse.id}.`); + } + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + throw new Error(`No warehouse zone found for yard ${warehouseYard.id}.`); + } + + return { + djiboutiYard, + ethiopiaYard, + serviceType, + containerType, + wagonType, + company, + importerProfile, + exporterProfile, + warehouse, + warehouseYard, + warehouseZone, + }; +} + +async function ensureProfile(repo: any, companyId: string, type: ProfileType, reference: string): Promise { + const existing = await repo.findOne({ where: { companyId, type } }); + if (existing) return existing; + return repo.save( + repo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + businessLicense: `${reference}-LICENSE`, + }), + ); +} + +async function seedScenarioTrain(manager: any, scenario: ScenarioTrain, refs: Awaited>, now: Date) { + const locomotiveRepo = manager.getRepository(Locomotive); + const trainSetRepo = manager.getRepository(TrainSet); + const scheduleRepo = manager.getRepository(TrainSchedule); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const wagonRepo = manager.getRepository(Wagon); + + const departure = addHours(now, scenario.departureOffsetHours); + const arrival = addHours(now, scenario.arrivalOffsetHours); + const isArrived = scenario.status === 'ARRIVED'; + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const totalWeightTons = scenario.bookings.reduce((sum, booking) => sum + booking.weightTons, 0); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'GP-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'GP-DEMO-LOCO', + name: 'Gate Pass Scenario Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: originYard.id, + }), + )); + + let schedule = await scheduleRepo.findOne({ where: { trainNumber: scenario.trainNumber } }); + let trainSet: TrainSet | null = schedule?.trainSetId + ? await trainSetRepo.findOne({ where: { id: schedule.trainSetId } }) + : null; + + if (!trainSet) { + trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }), + ); + } else { + await trainSetRepo.update(trainSet.id, { + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }); + } + if (!trainSet) { + throw new Error(`Could not create train set for ${scenario.trainNumber}`); + } + const trainSetId = trainSet.id; + + if (!schedule) { + schedule = scheduleRepo.create({ trainNumber: scenario.trainNumber }); + } + Object.assign(schedule, { + trainSetId, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isArrived ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: scenario.status, + direction: scenario.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + schedule = await scheduleRepo.save(schedule); + + for (const [index, bookingSpec] of scenario.bookings.entries()) { + const sequenceNo = index + 1; + const wagon = await ensureWagon(manager, scenario, sequenceNo, refs.wagonType.id, originYard.id, schedule.id); + const trainSetWagon = await ensureTrainSetWagon( + trainSetWagonRepo, + trainSetId, + refs.wagonType.id, + wagon.id, + sequenceNo, + bookingSpec.weightTons, + isArrived, + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const booking = await ensureBooking(manager, scenario, bookingSpec, refs, departure, now, schedule.id); + const bookingContainer = await ensureBookingContainer(manager, booking.id, refs.containerType.id, bookingSpec); + const allocation = await ensureAllocation(manager, trainSetWagon.id, booking.id, bookingSpec.weightTons, isArrived, now); + const container = await ensureContainer(manager, booking.id, bookingContainer.id, allocation.id, wagon.id, sequenceNo, bookingSpec, refs.containerType.id, isArrived); + await ensureContainerItem(manager, allocation.id, bookingContainer.id, container.id, refs.containerType.id, sequenceNo, bookingSpec); + await ensureScheduleBooking(manager, schedule.id, booking.id); + if (scenario.direction === 'EXPORT') { + await ensureExportInventory(manager, refs, booking.id, container.id, bookingSpec.weightTons, isArrived, now); + } + } + + if (scenario.direction === 'IMPORT') { + await ensureImportOperation(manager, schedule.id, scenario, departure, isArrived); + } + + return schedule; +} + +async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: number, wagonTypeId: string, yardId: string, scheduleId: string): Promise { + const repo = manager.getRepository(Wagon); + const wagonNumber = `${scenario.trainNumber}-W${String(sequenceNo).padStart(2, '0')}`; + const existing = await repo.findOne({ where: { wagonNumber } }); + const values = { + wagonNumber, + wagonTypeId, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: yardId, + currentTrainScheduleId: scheduleId, + notes: 'Gate-pass scenario seed wagon', + }; + return repo.save(repo.create({ ...(existing ?? {}), ...values })); +} + +async function ensureTrainSetWagon(repo: any, trainSetId: string, wagonTypeId: string, wagonId: string, sequenceNo: number, weightTons: number, isArrived: boolean): Promise { + const existing = await repo.findOne({ where: { trainSetId, sequenceNo } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetId, + wagonTypeId, + physicalWagonId: wagonId, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: isArrived ? 'DEPARTED' : 'LOADED', + }), + ); +} + +async function ensureBooking(manager: any, scenario: ScenarioTrain, bookingSpec: ScenarioTrain['bookings'][number], refs: Awaited>, departure: Date, now: Date, scheduleId: string): Promise { + const repo = manager.getRepository(Booking); + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const existing = await repo.findOne({ where: { reference: bookingSpec.reference } }); + const profile = scenario.direction === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; + const hasFirstMile = bookingSpec.mileVariant === 'FIRST_MILE'; + const hasLastMile = bookingSpec.mileVariant === 'LAST_MILE'; + + return repo.save( + repo.create({ + ...(existing ?? {}), + reference: bookingSpec.reference, + companyId: refs.company.id, + companyProfileId: profile.id, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: scenario.status === 'ARRIVED' ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: scenario.direction, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `${scenario.direction} gate-pass scenario ${bookingSpec.mileVariant.toLowerCase().replace('_', ' ')}`, + cargoTotalWeightVgm: bookingSpec.weightTons * 1000, + firstMilePickupAddress: hasFirstMile ? 'Customer factory pickup - Addis Ababa' : null, + firstMilePickupLat: hasFirstMile ? 9.03 : null, + firstMilePickupLng: hasFirstMile ? 38.74 : null, + lastMileDeliveryAddress: hasLastMile ? 'Customer warehouse delivery - Addis Ababa' : null, + lastMileDeliveryLat: hasLastMile ? 8.98 : null, + lastMileDeliveryLng: hasLastMile ? 38.8 : null, + trainScheduleId: scheduleId, + schedulingStatus: scenario.status === 'ARRIVED' ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }), + ); +} + +async function ensureBookingContainer(manager: any, bookingId: string, containerTypeId: string, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(BookingContainer); + const existing = await repo.findOne({ where: { bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + bookingId, + containerTypeId, + containerNumber: bookingSpec.containerNumber, + containerSize: '40', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: bookingSpec.weightTons, + totalVgmTons: bookingSpec.weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); +} + +async function ensureAllocation(manager: any, trainSetWagonId: string, bookingId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WagonBookingAllocation); + const existing = await repo.findOne({ where: { trainSetWagonId, bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetWagonId, + bookingId, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: isArrived ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); +} + +async function ensureContainer(manager: any, bookingId: string, bookingContainerId: string, allocationId: string, wagonId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number], containerTypeId: string, isArrived: boolean): Promise { + const repo = manager.getRepository(Container); + const existing = await repo.findOne({ where: { containerNumber: bookingSpec.containerNumber } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + containerNumber: bookingSpec.containerNumber, + containerTypeId, + wagonId, + position, + tareWeight: 3800, + maxGrossWeight: 30480, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + status: isArrived ? 'IN_TRANSIT' : 'LOADED', + bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId, + }), + ); +} + +async function ensureContainerItem(manager: any, allocationId: string, bookingContainerId: string, containerId: string, containerTypeId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(WagonAllocationContainerItem); + await repo.delete({ wagonBookingAllocationId: allocationId }); + await repo.save( + repo.create({ + wagonBookingAllocationId: allocationId, + bookingContainerId, + containerId, + containerNumber: bookingSpec.containerNumber, + containerTypeId, + positionOnWagon: position, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + chassisNumber: `CHS-${bookingSpec.containerNumber}`, + grossWeightTons: bookingSpec.weightTons, + }), + ); +} + +async function ensureScheduleBooking(manager: any, scheduleId: string, bookingId: string): Promise { + const repo = manager.getRepository(TrainScheduleBooking); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save(repo.create({ ...(existing ?? {}), trainScheduleId: scheduleId, bookingId })); +} + +async function ensureExportInventory(manager: any, refs: Awaited>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + warehouseId: refs.warehouse.id, + yardId: refs.warehouseYard.id, + zoneId: refs.warehouseZone.id, + bookingId, + containerId, + quantity: 1, + weight: weightTons * 1000, + status: 'LOADED', + inspectionStatus: 'PASSED', + arrivedAt: addHours(now, -24), + inspectedAt: addHours(now, -22), + readyForLoadingAt: addHours(now, -20), + loadedAt: isArrived ? addHours(now, -16) : null, + notes: '[GP-SCENARIO] Export train gate-pass scenario inventory', + }), + ); +} + +async function ensureImportOperation(manager: any, scheduleId: string, scenario: ScenarioTrain, departure: Date, isArrived: boolean): Promise { + const repo = manager.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + trainScheduleId: scheduleId, + documents: existing?.documents ?? {}, + gatepassGrantedAt: null, + readyForLoadingAt: null, + loadedOnTrainAt: null, + departedFromDjiboutiAt: isArrived ? departure : null, + loadListGeneratedAt: null, + performedBy: 'Gate Pass Scenario Seeder', + notes: `[GP-SCENARIO] ${scenario.trainNumber}; fill gate-pass dates during testing`, + }), + ); +} + +main().catch((error) => { + console.error('Gate-pass train scenario seed failed:', error); + process.exit(1); +}); 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 9670243c9..a619c7c05 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -468,15 +468,15 @@ export class DemoBookingsSeeder { const djibouti = yardByCode.get("DJIBOUTI"); const addis = yardByCode.get("ADDIS_ABABA"); if (djibouti && addis) { - const routeName = "Djibouti → Addis Ababa"; - let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + let route = await manager.getRepository(Route).findOne({ + where: { originYardId: djibouti.id, destinationYardId: addis.id }, + }); if (!route) { route = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: routeName, originYardId: djibouti.id, destinationYardId: addis.id, - isActive: true, + status: 'AVAILABLE', }), ); await manager.getRepository(RouteMilestone).save([ @@ -484,11 +484,13 @@ export class DemoBookingsSeeder { routeId: route.id, yardId: djibouti.id, sequenceNo: 1, + distanceKm: 0, }), manager.getRepository(RouteMilestone).create({ routeId: route.id, yardId: addis.id, sequenceNo: 2, + distanceKm: 780, }), ]); } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ba0d6da19..56ca98626 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -80,6 +80,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'), + perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), + perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), + perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -149,6 +152,9 @@ export const FREIGHT_PERMS = { finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', createBooking: 'edr_freight_app:contracts:create_booking', opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review', + clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', + clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', + clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -237,6 +243,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, @@ -247,6 +255,7 @@ export const ROLE_PERMISSION_PRESETS = { // damage reports. Read-only on the contract; no booking creation. glDjibouti: [ FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts new file mode 100644 index 000000000..e33baa7fd --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts @@ -0,0 +1,1131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; +import { randomUUID } from 'crypto'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + Company, + CompanyKind, + CompanyNationality, + CompanyStatus, + CompanyType, +} from '../modules/companies/entities/company.entity'; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from '../modules/companies/entities/company-profile.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; +import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; + +const CUSTOMER_TIN = 'US12DEMO01'; + +const DEMO_TRAINS = [ + { + trainNumber: 'US12-DJI-IND-01', + direction: 'IMPORT', + originCode: 'NAGAD', + destinationCode: 'INDODE', + departureHoursAgo: 30, + arrivalHoursAgo: 14, + }, + { + trainNumber: 'US12-IND-DJI-01', + direction: 'EXPORT', + originCode: 'INDODE', + destinationCode: 'NAGAD', + departureHoursAgo: 28, + arrivalHoursAgo: 12, + }, + { + trainNumber: 'US12-DJI-IND-LM-02', + direction: 'IMPORT', + originCode: 'NAGAD', + destinationCode: 'INDODE', + departureHoursAgo: 24, + arrivalHoursAgo: 8, + }, + { + trainNumber: 'US12-IND-DJI-LM-02', + direction: 'EXPORT', + originCode: 'INDODE', + destinationCode: 'NAGAD', + departureHoursAgo: 22, + arrivalHoursAgo: 6, + }, +] as const; + +const TRAIN_DEMO_BOOKINGS = [ + { + reference: 'US12-IMP-FM-001', + trainNumber: 'US12-DJI-IND-01', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: true, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 27, + totalAmount: 18450, + pickupAddress: 'Doraleh Container Terminal, Djibouti', + pickupLat: 11.5881, + pickupLng: 43.1372, + deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', + deliveryLat: 8.7566, + deliveryLng: 38.9846, + }, + { + reference: 'US12-IMP-NOFM-001', + trainNumber: 'US12-DJI-IND-01', + tradeDirection: 'IMPORT', + freightType: 'BULK', + withFirstMile: false, + withLastMile: false, + containerCode: null, + cargoCode: 'BULK', + weightTons: 42, + totalAmount: 22100, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-EXP-FM-001', + trainNumber: 'US12-IND-DJI-01', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: true, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 19, + totalAmount: 15680, + pickupAddress: 'Indode export truck gate, Ethiopia', + pickupLat: 8.7566, + pickupLng: 38.9846, + deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', + deliveryLat: 11.5536, + deliveryLng: 43.1103, + }, + { + reference: 'US12-EXP-NOFM-001', + trainNumber: 'US12-IND-DJI-01', + tradeDirection: 'EXPORT', + freightType: 'BULK', + withFirstMile: false, + withLastMile: false, + containerCode: null, + cargoCode: 'BULK', + weightTons: 55, + totalAmount: 29800, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-IMP-LM-TRAIN-001', + trainNumber: 'US12-DJI-IND-LM-02', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: true, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 31, + totalAmount: 20300, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', + deliveryLat: 8.7581, + deliveryLng: 38.9834, + }, + { + reference: 'US12-EXP-LM-TRAIN-001', + trainNumber: 'US12-IND-DJI-LM-02', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: true, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 21, + totalAmount: 17600, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', + deliveryLat: 11.5549, + deliveryLng: 43.1121, + }, +] as const; + +const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ + { + reference: 'US12-EXP-FM-TRUCK-001', + trainNumber: null, + originCode: 'INDODE', + destinationCode: 'NAGAD', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: false, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 24, + totalAmount: 14800, + pickupAddress: 'Customer factory gate, Addis Ababa', + pickupLat: 8.9806, + pickupLng: 38.8736, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-EXP-NOFM-TRUCK-001', + trainNumber: null, + originCode: 'INDODE', + destinationCode: 'NAGAD', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: false, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 18, + totalAmount: 11200, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + customerTruckPlateNumber: 'ET-CUS-2046', + customerTruckDriverName: 'Dawit Customer Carrier', + customerTruckType: 'Container Chassis', + customerTruckContainerNumber: 'USDU1234567', + }, +] as const; + +const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; + +@Injectable() +export class PaidIndodeDemoBookingsSeeder { + private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + await this.dataSource.transaction(async (manager) => { + const refs = await this.ensureReferenceData(manager); + const schedules = await this.ensureArrivedTrains(manager, refs); + const bookings = await this.ensureBookings(manager, refs, schedules); + await this.ensureTrainLinks(manager, refs, schedules, bookings); + await this.ensureGatepasses(manager, schedules); + await this.ensureImportWarehouseInventory(manager, bookings); + }); + + this.logger.log( + `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, + ); + } catch (error) { + this.logger.error( + `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async ensureReferenceData(manager: EntityManager) { + await manager.getRepository(Yard).upsert( + [ + { + code: 'INDODE', + label: 'Indode Terminal', + country: 'Ethiopia', + isActive: true, + displayOrder: 1, + }, + { + code: 'NAGAD', + label: 'Nagad Terminal, Djibouti', + country: 'Djibouti', + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + [ + { + code: 'RAIL_CONTAINER_FIRST_LAST', + serviceName: 'Rail Freight with First and Last Mile', + description: 'Rail movement with first-mile pickup and last-mile delivery', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 15, + isActive: true, + displayOrder: 3, + }, + { + code: 'RAIL_CONTAINER_LAST_MILE', + serviceName: 'Rail Freight with Last Mile', + description: 'Rail movement with last-mile delivery from terminal', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 8, + isActive: true, + displayOrder: 4, + }, + { + code: 'RAIL_CONTAINER', + serviceName: 'Rail Freight', + description: 'Rail movement without first-mile pickup', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: 'RAIL_CONTAINER_FIRST_MILE', + serviceName: 'Rail Freight with First Mile', + description: 'Rail movement with first-mile pickup to terminal', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 10, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + [ + { + code: '20FT', + label: '20FT Standard', + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: '40FT', + label: '40FT Standard', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: 'GENERAL_CARGO', + cargoTypeName: 'General Cargo', + showFreeTextBox: true, + unitOfMeasure: null, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: 'BULK', + cargoTypeName: 'Bulk Cargo', + showFreeTextBox: true, + unitOfMeasure: CargoUnitOfMeasure.PerTon, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(WagonType).upsert( + { + code: 'US12-DEMO', + name: 'US12 Demo Flat/Bulk Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER', 'BULK'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 14, + supportsContainer: true, + maxContainerGrossT: 40, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'US12 Indode Demo Customer PLC', + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: CUSTOMER_TIN, + vatNumber: 'VAT-US12-001', + fanNumber: 'US12000000000001', + country: 'Ethiopia', + nationality: CompanyNationality.Ethiopian, + address: 'Bole Road, Addis Ababa, Ethiopia', + phone: '251911120012', + email: 'us12.indode.demo@edr.local', + website: 'https://edr.local/us12-demo', + contactPersonName: 'Aster Bekele', + contactPersonPhone: '251911120013', + generalManagerName: 'Mekonnen Desta', + generalManagerEmail: 'manager.us12.demo@edr.local', + generalManagerPhone: '251911120014', + licenceNumber: 'LIC-US12-2026', + region: 'Addis Ababa', + zone: 'Bole', + woreda: '03', + kebele: '12', + houseNo: 'US12-01', + attributes: { + seededBy: 'PaidIndodeDemoBookingsSeeder', + note: 'Paid customer with import/export demo bookings for US12.', + } as any, + }, + { conflictPaths: { tin: true } }, + ); + + const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); + await manager.getRepository(CompanyProfile).upsert( + [ + { + companyId: company.id, + type: ProfileType.importer, + reference: 'US12-IMP', + status: ProfileStatus.Active, + businessLicense: 'BL-US12-IMP-2026', + attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, + }, + { + companyId: company.id, + type: ProfileType.exporter, + reference: 'US12-EXP', + status: ProfileStatus.Active, + businessLicense: 'BL-US12-EXP-2026', + attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, + }, + ], + { conflictPaths: { reference: true } }, + ); + + const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = + await Promise.all([ + manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), + manager + .getRepository(ServiceType) + .find({ + where: { + code: In([ + 'RAIL_CONTAINER', + 'RAIL_CONTAINER_FIRST_MILE', + 'RAIL_CONTAINER_LAST_MILE', + 'RAIL_CONTAINER_FIRST_LAST', + ]), + }, + }), + manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), + manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), + manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), + manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), + manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), + ]); + + return { + company, + importerProfile, + exporterProfile, + yards: new Map(yards.map((yard) => [yard.code, yard])), + serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), + containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), + cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), + wagonType, + }; + } + + private async ensureArrivedTrains( + manager: EntityManager, + refs: Awaited>, + ): Promise> { + const schedules = new Map(); + const now = new Date(); + + for (const demo of DEMO_TRAINS) { + const origin = refs.yards.get(demo.originCode); + const destination = refs.yards.get(demo.destinationCode); + if (!origin || !destination) { + throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); + } + + const departure = this.addHours(now, -demo.departureHoursAgo); + const arrival = this.addHours(now, -demo.arrivalHoursAgo); + const locomotive = await this.ensureLocomotive(manager, origin.id); + const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); + const schedule = await this.ensureTrainSchedule(manager, { + trainNumber: demo.trainNumber, + trainSetId: trainSet.id, + originStationId: origin.id, + destinationStationId: destination.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: departure, + actualArrivalAt: arrival, + direction: demo.direction, + }); + + await manager.getRepository(TrainSet).update(trainSet.id, { + totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) + .reduce((sum, booking) => sum + booking.weightTons, 0), + totalLengthMeters: 28, + wagonCount: 2, + status: 'COMPLETED', + }); + schedules.set(demo.trainNumber, schedule); + } + + return schedules; + } + + private async ensureBookings( + manager: EntityManager, + refs: Awaited>, + schedules: Map, + ): Promise> { + const bookingRepo = manager.getRepository(Booking); + const bookingContainerRepo = manager.getRepository(BookingContainer); + const firstMileRepo = manager.getRepository(FirstMile); + const lastMileRepo = manager.getRepository(LastMile); + const now = new Date(); + const references = DEMO_BOOKINGS.map((booking) => booking.reference); + const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); + const existingBookingIds = existingBookings.map((booking) => booking.id); + const firstMileVehicle = await this.ensureFirstMileVehicle(manager); + + if (existingBookingIds.length) { + const existingInventory = await manager.getRepository(WarehouseInventory).find({ + where: { bookingId: In(existingBookingIds) }, + select: { id: true }, + }); + const existingInventoryIds = existingInventory.map((item) => item.id); + if (existingInventoryIds.length) { + await manager.getRepository(WarehouseActivityLog).delete({ + inventoryId: In(existingInventoryIds), + }); + await manager.getRepository(WarehouseInventory).delete({ + id: In(existingInventoryIds), + }); + } + await this.deleteBookingTrainChildren(manager, existingBookingIds); + await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); + await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); + await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); + } + + for (const demo of DEMO_BOOKINGS) { + const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; + if (demo.trainNumber && !schedule) { + throw new Error(`US12 demo booking missing train: ${demo.reference}`); + } + + const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; + const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); + const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); + const origin = originCode ? refs.yards.get(originCode) : null; + const destination = destinationCode ? refs.yards.get(destinationCode) : null; + const serviceType = refs.serviceTypes.get( + demo.withFirstMile && demo.withLastMile + ? 'RAIL_CONTAINER_FIRST_LAST' + : demo.withFirstMile + ? 'RAIL_CONTAINER_FIRST_MILE' + : demo.withLastMile + ? 'RAIL_CONTAINER_LAST_MILE' + : 'RAIL_CONTAINER', + ); + const cargoType = refs.cargoTypes.get(demo.cargoCode); + const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; + + if (!origin || !destination || !serviceType || !cargoType) { + throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); + } + + await bookingRepo.upsert( + { + reference: demo.reference, + companyId: refs.company.id, + companyProfileId: profile.id, + isGovernment: false, + status: + 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber + ? 'TRUCK_ASSIGNED' + : demo.trainNumber + ? 'IN_TRANSIT' + : 'PAID', + scheduledDate: schedule?.scheduledDepartureDate ?? now, + estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, + totalAmount: demo.totalAmount, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + firstMilePickupAddress: demo.pickupAddress, + firstMilePickupLat: demo.pickupLat, + firstMilePickupLng: demo.pickupLng, + lastMileDeliveryAddress: demo.deliveryAddress, + lastMileDeliveryLat: demo.deliveryLat, + lastMileDeliveryLng: demo.deliveryLng, + customerTruckPlateNumber: + 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, + customerTruckDriverName: + 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, + customerTruckType: + 'customerTruckType' in demo ? demo.customerTruckType : null, + customerTruckContainerNumber: + 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, + customerTruckAssignedAt: + 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber + ? this.addHours(now, -2) + : null, + customerTruckArrivedAt: null, + customsClearingEnabled: false, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demo.tradeDirection, + freightType: demo.freightType, + cargoTypeId: cargoType.id, + cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demo.weightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + pnrCode: `PNR-${demo.reference}`, + versionNumber: 1, + approvedByStaffAt: now, + customerSignedAt: now, + fullyExecutedAt: now, + pricingBreakdown: { + paid: true, + source: 'PaidIndodeDemoBookingsSeeder', + firstMileIncluded: demo.withFirstMile, + lastMileIncluded: demo.withLastMile, + }, + priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, + wagonsRequired: 1, + schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', + scheduledAt: demo.trainNumber ? now : null, + trainScheduleId: schedule?.id ?? null, + paymentDeadline: null, + selectedForBatchAt: demo.trainNumber ? now : null, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); + + if (demo.freightType === 'CONTAINER' && demo.containerCode) { + const containerType = refs.containerTypes.get(demo.containerCode); + if (!containerType) { + throw new Error(`US12 demo booking missing container type: ${demo.reference}`); + } + await bookingContainerRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + containerNumber: this.containerNumber(demo.reference), + containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: demo.weightTons, + totalVgmTons: demo.weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }); + } + + if (demo.withFirstMile) { + await firstMileRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + status: 'RECEIVED_TO_PORT', + advancedPayment: demo.totalAmount, + remainingPayment: 0, + estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, + exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, + vehicleId: firstMileVehicle.id, + }); + } + + if (demo.withLastMile) { + await lastMileRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + status: 'DELIVERED', + advancedPayment: demo.totalAmount, + remainingPayment: 0, + estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, + exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, + vehicleId: null, + }); + } + } + + const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); + return new Map(savedBookings.map((booking) => [booking.reference, booking])); + } + + private async ensureTrainLinks( + manager: EntityManager, + refs: Awaited>, + schedules: Map, + bookings: Map, + ): Promise { + const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); + const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; + const wagonLength = Number(refs.wagonType.lengthMeters) || 14; + const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; + + for (const demo of TRAIN_DEMO_BOOKINGS) { + const schedule = schedules.get(demo.trainNumber); + const booking = bookings.get(demo.reference); + if (!schedule || !booking) continue; + + const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); + const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; + const wagon = await this.ensureWagon(manager, { + wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, + wagonTypeId: refs.wagonType.id, + yardId: schedule.destinationStationId, + trainScheduleId: schedule.id, + trainSetWagonId: null, + tareWeight, + capacityTons: wagonCapacity, + }); + + let trainSetWagon = await trainSetWagonRepo.findOne({ + where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, + }); + trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + ...(trainSetWagon ? { id: trainSetWagon.id } : {}), + trainSetId: schedule.trainSetId, + wagonTypeId: refs.wagonType.id, + physicalWagonId: wagon.id, + sequenceNo: sequence, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: demo.weightTons, + status: 'DEPARTED', + }), + ); + + await manager.getRepository(Wagon).update(wagon.id, { + trainSetWagonId: trainSetWagon.id, + currentTrainScheduleId: schedule.id, + currentYardId: schedule.destinationStationId, + status: WagonStatus.Assigned, + }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: demo.weightTons, + loadType: demo.freightType, + status: 'DEPARTED', + confirmedAt: schedule.actualDepartureAt ?? new Date(), + }), + ); + + if (demo.freightType === 'CONTAINER') { + const bookingContainer = await manager.getRepository(BookingContainer).findOne({ + where: { bookingId: booking.id }, + }); + const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; + await containerItemRepo.insert({ + id: randomUUID(), + wagonBookingAllocationId: allocation.id, + bookingContainerId: bookingContainer?.id ?? null, + containerNumber: this.containerNumber(demo.reference), + containerTypeId: containerType?.id ?? null, + positionOnWagon: 1, + sealNumber: `SEAL-${demo.reference}`, + chassisNumber: `CHS-${demo.reference}`, + grossWeightTons: demo.weightTons, + }); + } + + await scheduleBookingRepo.insert({ + id: randomUUID(), + trainScheduleId: schedule.id, + bookingId: booking.id, + }); + } + } + + private async ensureGatepasses( + manager: EntityManager, + schedules: Map, + ): Promise { + const repo = manager.getRepository(ImportDjiboutiOperation); + const securedAt = this.addHours(new Date(), -20); + + for (const schedule of schedules.values()) { + const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); + await repo.save( + repo.create({ + ...(existing ? { id: existing.id } : {}), + trainScheduleId: schedule.id, + documents: { + ...(existing?.documents ?? {}), + GATE_PASS: { + reference: `GP-${schedule.trainNumber}`, + uploadedAt: securedAt.toISOString(), + uploadedBy: 'PaidIndodeDemoBookingsSeeder', + notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', + }, + }, + gatepassGrantedAt: securedAt, + performedBy: 'PaidIndodeDemoBookingsSeeder', + notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', + }), + ); + } + } + + private async ensureImportWarehouseInventory( + manager: EntityManager, + bookings: Map, + ): Promise { + const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); + if (!warehouse) { + this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); + return; + } + + for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { + const booking = bookings.get(demo.reference); + if (!booking) continue; + + const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); + if (!yard) { + this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); + continue; + } + const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); + if (!zone) { + this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); + continue; + } + + const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); + const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: warehouse.id, + yardId: yard.id, + zoneId: zone.id, + bookingId: booking.id, + quantity: demo.freightType === 'CONTAINER' ? 1 : 1, + weight: demo.weightTons, + volume: null, + grnNumber, + status: 'UNLOADED', + inspectionStatus: null, + arrivedAt, + unloadedAt: arrivedAt, + notes: [ + `GRN Number: ${grnNumber}`, + 'Direction: IMPORT', + `Train: ${demo.trainNumber}`, + `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, + 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', + ].join('\n'), + }), + ); + + await manager.getRepository(WarehouseActivityLog).save( + manager.getRepository(WarehouseActivityLog).create({ + inventoryId: saved.id, + warehouseId: warehouse.id, + activityType: 'INVENTORY_UNLOADED', + description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, + performedBy: 'PaidIndodeDemoBookingsSeeder', + }), + ); + } + } + + private async findWarehouseYard( + manager: EntityManager, + warehouseId: string, + freightType: string, + ): Promise { + const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; + return ( + (await manager.getRepository(WarehouseYard).findOne({ + where: { warehouseId, type: preferredType as any }, + })) ?? + (await manager.getRepository(WarehouseYard).findOne({ + where: { warehouseId }, + })) + ); + } + + private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const allocations = await allocationRepo.find({ + where: { bookingId: In(bookingIds) }, + select: { id: true }, + }); + const allocationIds = allocations.map((allocation) => allocation.id); + if (allocationIds.length) { + await manager.getRepository(WagonAllocationContainerItem).delete({ + wagonBookingAllocationId: In(allocationIds), + }); + } + await allocationRepo.delete({ bookingId: In(bookingIds) }); + await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); + } + + private async ensureLocomotive( + manager: EntityManager, + currentYardId: string, + ): Promise { + const repo = manager.getRepository(Locomotive); + const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); + if (existing) { + await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); + return { ...existing, currentYardId, status: 'AVAILABLE' }; + } + + return repo.save( + repo.create({ + code: 'US12-DEMO-LOCO', + name: 'US12 Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId, + }), + ); + } + + private async ensureTrainSet( + manager: EntityManager, + trainNumber: string, + locomotiveId: string, + ): Promise { + const schedule = await manager.getRepository(TrainSchedule).findOne({ + where: { trainNumber }, + }); + if (schedule) { + const existing = await manager.getRepository(TrainSet).findOneByOrFail({ + id: schedule.trainSetId, + }); + await manager.getRepository(TrainSet).update(existing.id, { + locomotiveId, + status: 'COMPLETED', + }); + return { ...existing, locomotiveId, status: 'COMPLETED' }; + } + + return manager.getRepository(TrainSet).save( + manager.getRepository(TrainSet).create({ + locomotiveId, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'COMPLETED', + }), + ); + } + + private async ensureTrainSchedule( + manager: EntityManager, + input: { + trainNumber: string; + trainSetId: string; + originStationId: string; + destinationStationId: string; + scheduledDepartureDate: Date; + scheduledArrivalDate: Date; + actualDepartureAt: Date; + actualArrivalAt: Date; + direction: 'IMPORT' | 'EXPORT'; + }, + ): Promise { + const repo = manager.getRepository(TrainSchedule); + const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); + const nextSchedule = repo.create({ + ...(existing ? { id: existing.id } : {}), + trainSetId: input.trainSetId, + originStationId: input.originStationId, + destinationStationId: input.destinationStationId, + scheduledDepartureDate: input.scheduledDepartureDate, + scheduledArrivalDate: input.scheduledArrivalDate, + actualDepartureAt: input.actualDepartureAt, + actualArrivalAt: input.actualArrivalAt, + status: TrainScheduleStatus.Arrived, + trainNumber: input.trainNumber, + direction: input.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + return repo.save(nextSchedule); + } + + private async ensureWagon( + manager: EntityManager, + input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + trainSetWagonId: string | null; + tareWeight: number; + capacityTons: number; + }, + ): Promise { + const repo = manager.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + return repo.save( + repo.create({ + ...(existing ? { id: existing.id } : {}), + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + trainSetWagonId: input.trainSetWagonId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: WagonStatus.Assigned, + notes: 'US12 paid Indode demo seed wagon', + }), + ); + } + + private async ensureFirstMileVehicle(manager: EntityManager): Promise { + const driverRepo = manager.getRepository(Driver); + const vehicleRepo = manager.getRepository(Vehicle); + const licenseNumber = 'US12-FM-LIC-001'; + const plateNumber = 'ET-FM-1201'; + + await driverRepo.upsert( + { + licenseNumber, + firstName: 'Tesfaye', + lastName: 'Firstmile', + email: 'tesfaye.firstmile@edr.local', + phoneNumber: '251911120120', + licenseExpiryDate: this.addHours(new Date(), 24 * 365), + status: DriverStatus.ACTIVE, + vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], + notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', + }, + { conflictPaths: { licenseNumber: true } }, + ); + const driver = await driverRepo.findOneByOrFail({ licenseNumber }); + + await vehicleRepo.upsert( + { + plateNumber, + registrationNumber: 'US12-FM-REG-001', + vehicleType: VehicleType.TRUCK, + manufacturer: 'Sinotruk', + model: 'HOWO Container Carrier', + year: 2024, + fuelType: FuelType.DIESEL, + capacity: 40, + status: VehicleStatus.ACTIVE, + assignedDriverId: driver.id, + assignedDriverName: `${driver.firstName} ${driver.lastName}`, + description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', + estimatedDistanceKm: 35, + actualDistanceKm: 34.6, + }, + { conflictPaths: { plateNumber: true } }, + ); + const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); + await manager.query( + `UPDATE freight.vehicles + SET trailer_plate_no = $2, + assigned_driver_id = $3, + assigned_driver_name = $4, + updated_at = NOW() + WHERE id = $1`, + [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], + ); + return vehicleRepo.findOneByOrFail({ plateNumber }); + } + + private containerNumber(reference: string): string { + const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); + return `US12${suffix}`; + } + + private addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); + } +} 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 ae48ada66..02a05602e 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -286,15 +286,15 @@ export class PricingDataSeeder { const routeRepo = manager.getRepository(Route); const milestoneRepo = manager.getRepository(RouteMilestone); - const routeName = "Addis Ababa → Dire Dawa"; - let route = await routeRepo.findOneBy({ name: routeName }); + let route = await routeRepo.findOne({ + where: { originYardId: addis.id, destinationYardId: direDawa.id }, + }); if (!route) { route = await routeRepo.save( routeRepo.create({ - name: routeName, originYardId: addis.id, destinationYardId: direDawa.id, - isActive: true, + status: 'AVAILABLE', }), ); await milestoneRepo.save([ @@ -302,11 +302,13 @@ export class PricingDataSeeder { routeId: route.id, yardId: addis.id, sequenceNo: 1, + distanceKm: 0, }), milestoneRepo.create({ routeId: route.id, yardId: direDawa.id, sequenceNo: 2, + distanceKm: 445, }), ]); this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa"); diff --git a/apps/edr-freight-api/src/types/multer-globals.d.ts b/apps/edr-freight-api/src/types/multer-globals.d.ts new file mode 100644 index 000000000..0bc672a9e --- /dev/null +++ b/apps/edr-freight-api/src/types/multer-globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..567ecf074 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, @@ -14,13 +15,22 @@ import { Send, Settings, ShieldCheck, + Ship, SlidersHorizontal, Train, Truck, Users, Wallet, } from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { + Navigate, + Outlet, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; import { useAuth } from "./auth/useAuth"; @@ -35,8 +45,12 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; +import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; +import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; -import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -57,6 +71,12 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; +import FuelStatsPage from "./pages/fleet/FuelStatsPage"; +import { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -67,6 +87,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -133,7 +154,22 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Document Clearance", href: "/dashboard/contracts/clearance", icon: , - permission: FREIGHT_PERMS.contracts.clearanceReview, + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, }, { label: "Train Schedules", @@ -164,6 +200,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -200,6 +242,36 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -343,6 +415,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, // { // label: "Train scheduling rules", // href: "/dashboard/configuration/train-scheduling-rules", @@ -459,7 +535,11 @@ const App = () => { /> } + element={ + + + + } /> {/* Contracts (Path A/B) */} @@ -487,11 +567,40 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} + } @@ -499,11 +608,34 @@ const App = () => { + } /> + } /> + } /> + + + + } + /> + + + + } + /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} { /> - - - } + element={} /> } /> } /> @@ -725,6 +853,54 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { } /> + + + + } + /> } /> } /> } /> @@ -839,4 +1023,21 @@ const App = () => { ); }; +/** Redirect removed milestones page to document clearance. */ +function BookingMilestonesRedirect() { + const { id } = useParams(); + return ( + + ); +} + +/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ +function LegacyGlEthiopiaClearanceRedirect() { + const { id } = useParams(); + if (id) { + return ; + } + return ; +} + export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 01a0db69a..13ac9577d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -65,7 +65,6 @@ export function BookingActionsMenu({ }; const hasMenu = listRowHasActions(row, user); - const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( @@ -117,19 +116,6 @@ export function BookingActionsMenu({ onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > - {variant === "table" && primary && ( - - )} - void; /** Hide the inline progress summary (e.g. when the parent renders its own). */ hideSummary?: boolean; + /** Lock approve actions after document review phase completes. */ + approvalsLocked?: boolean; + /** Block new queries after pre-clearance finalization. */ + queriesLocked?: boolean; + /** Read-only audit view — no approve/query actions. */ + readOnly?: boolean; } const STATUS_META: Record< @@ -64,6 +70,9 @@ export function ClearanceReviewSection({ bookingId, onChanged, hideSummary, + approvalsLocked = false, + queriesLocked = false, + readOnly = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -147,6 +156,11 @@ export function ClearanceReviewSection({ return { total, approved, queried, pending, pct }; }, [customerDocs]); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -194,6 +208,9 @@ export function ClearanceReviewSection({ @@ -397,6 +414,9 @@ function StatPill({ function DocReviewCard({ doc, + approvalsLocked, + queriesLocked, + readOnly, note, queryOpen, onToggleQuery, @@ -407,6 +427,9 @@ function DocReviewCard({ busy, }: { doc: Freight.ClearanceDocument; + approvalsLocked: boolean; + queriesLocked: boolean; + readOnly: boolean; note: string; queryOpen: boolean; onToggleQuery: (open: boolean) => void; @@ -419,6 +442,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( )} - {hasFile && ( + {hasFile && !readOnly && ( {!queryOpen ? ( - - + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + + )} ) : ( void; + onDownloadFile?: (file: { id: string; name: string }) => void; +} + +function findMilestone( + milestones: Freight.IClearanceMilestone[] | undefined, + code: string, +): Freight.IClearanceMilestone | undefined { + return milestones?.find((m) => m.milestoneCode === code); +} + +/** + * Document Clearance detail layout: primary clearance workflow plus optional + * uploaded documents, post-booking risk assignment, and incident reporting tabs. + */ +export function ClearanceOpsTabs({ + bookingId, + milestones, + showOpsTabs = true, + clearanceTab, + workflowFiles = [], + showWorkflowFilesTab = false, + tradeDirection = "IMPORT", + onViewFile, + onDownloadFile, +}: ClearanceOpsTabsProps) { + const riskMs = findMilestone(milestones, "RISK_ASSIGNED"); + const hasOps = Boolean(bookingId); + const isExport = tradeDirection === "EXPORT"; + const uploadedDocCount = workflowFiles.filter((f) => { + if (!f.file) return false; + if (isExport) return f.category !== "duty"; + return true; + }).length; + const showDocuments = showWorkflowFilesTab && Boolean(onViewFile); + const hasTabs = (showOpsTabs && hasOps) || showDocuments; + + if (!hasTabs) { + return <>{clearanceTab}; + } + + return ( + + + Clearance + {showDocuments ? ( + } + rightSection={ + uploadedDocCount > 0 ? ( + + {uploadedDocCount} + + ) : undefined + } + > + Uploaded documents + + ) : null} + {showOpsTabs && riskMs ? ( + }> + Risk assignment + + ) : null} + {showOpsTabs && bookingId ? ( + }> + Incidents + + ) : null} + + + {clearanceTab} + + {showDocuments ? ( + + + + ) : null} + + {showOpsTabs && riskMs && bookingId ? ( + + + + + + ) : null} + + {showOpsTabs && bookingId ? ( + + + + + Log container or seal issues discovered during clearance handling. + + + + + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx new file mode 100644 index 000000000..ba93a790d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -0,0 +1,112 @@ +import { Check } from "lucide-react"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +const BRAND_GREEN = "var(--freight-brand, #0A6F4D)"; + +const IMPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_ET_OUTPUT", + "CUSTOMER_DUTY", + "GL_ET_POST_CLEARANCE", + "GL_DJ_COLLECTION", +] as const; + +const PHASE_LABELS: Record = { + CUSTOMER_INTAKE: "Customer docs", + GL_ET_REVIEW: "GL ET review", + GL_DJ_COLLECTION: "GL Djibouti DO", + GL_ET_OUTPUT: "Declaration", + CUSTOMER_DUTY: "Duty / customer pays", + GL_ET_POST_CLEARANCE: "Transit & finalize", + GL_DJ_LOADING: "Loading", + POST_TRANSIT: "Transit", +}; + +const EXPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_DJ_COLLECTION", + "GL_ET_OUTPUT", + "GL_ET_POST_CLEARANCE", +] as const; + +function phaseIndex(phases: readonly string[], current?: string | null): number { + if (!current) return 0; + const idx = phases.indexOf(current); + return idx >= 0 ? idx : 0; +} + +export function ClearancePhaseStepper({ + clearance, + tradeDirection, + compact = false, +}: { + clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null; + tradeDirection?: string; + compact?: boolean; +}) { + const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES; + const current = clearance?.phase ?? phases[0]; + const activeIdx = phaseIndex(phases, current); + + return ( + + {phases.map((phase, index) => { + const isComplete = index < activeIdx; + const isActive = index === activeIdx; + const isLast = index === phases.length - 1; + + return ( + + + + + {isComplete ? : null} + + + {PHASE_LABELS[phase] ?? phase} + + + {!isLast && ( + + )} + + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx new file mode 100644 index 000000000..1abffa761 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core"; +import { FileText, Receipt, Ship, Truck } from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; + +type TabValue = Freight.ClearanceWorkflowFileCategory; + +type TabConfig = { + value: TabValue; + label: string; + icon: typeof FileText; + emptyHint: string; +}; + +function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] { + if (tradeDirection === "EXPORT") { + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "djibouti", + label: "Release order", + icon: Ship, + emptyHint: "No release order uploaded yet.", + }, + { + value: "transit", + label: "Transit Permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; + } + + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "duty", + label: "Duty notice", + icon: Receipt, + emptyHint: "No duty notice or payment slip uploaded yet.", + }, + { + value: "transit", + label: "Transit permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; +} + +function subtitleForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Declaration, release order, and transit permit files for this clearance." + : "Declaration, duty notice, and transit permit files for this clearance."; +} + +function footerHintForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded." + : "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents."; +} + +export interface ClearanceUploadedDocumentsPanelProps { + files: Freight.ClearanceWorkflowFile[]; + tradeDirection?: string; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +} + +export function ClearanceUploadedDocumentsPanel({ + files, + tradeDirection = "IMPORT", + onView, + onDownload, +}: ClearanceUploadedDocumentsPanelProps) { + const tabConfig = useMemo( + () => tabConfigForTradeDirection(tradeDirection), + [tradeDirection], + ); + const isExport = tradeDirection === "EXPORT"; + + const visibleFiles = useMemo( + () => + isExport ? files.filter((f) => f.category !== "duty") : files, + [files, isExport], + ); + + const uploadedCount = visibleFiles.filter((f) => f.file).length; + + const defaultTab = + tabConfig.find((tab) => + visibleFiles.some((f) => f.category === tab.value && f.file), + )?.value ?? tabConfig[0]?.value ?? "declaration"; + + return ( + + + + {tabConfig.map((tab) => { + const count = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ).length; + const Icon = tab.icon; + return ( + } + rightSection={ + count > 0 ? ( + + {count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + {tabConfig.map((tab) => { + const items = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ); + const Icon = tab.icon; + + return ( + + {items.length > 0 ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + )} + + ); + })} + + + {uploadedCount === 0 ? ( + + {footerHintForTradeDirection(tradeDirection)} + + ) : null} + + ); +} + +function EmptyTabState({ + icon: Icon, + hint, +}: { + icon: typeof FileText; + hint: string; +}) { + return ( + + + + + + + {hint} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx new file mode 100644 index 000000000..17e5e4b70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx @@ -0,0 +1,155 @@ +import { + Badge, + Box, + Button, + Group, + Paper, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { Download, Eye, FileText } from "lucide-react"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { fileViewUrl } from "@/constants/apiConfig"; + +const CATEGORY_LABELS: Record< + Freight.ClearanceWorkflowFileCategory, + string +> = { + declaration: "Declaration", + duty: "Duty & taxes", + transit: "Transit", + djibouti: "Djibouti", +}; + +const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "declaration", + "duty", + "transit", + "djibouti", +]; + +const OWNER_LABELS: Record = { + customer: "Customer", + gl_et: "GL Ethiopia", + gl_dj: "GL Djibouti", +}; + +export interface ClearanceWorkflowFilesPanelProps { + files: Freight.ClearanceWorkflowFile[]; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; + title?: string; +} + +export function ClearanceWorkflowFilesPanel({ + files, + onView, + onDownload, + title = "Customs workflow documents", +}: ClearanceWorkflowFilesPanelProps) { + if (files.length === 0) return null; + + const grouped = CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + items: files.filter((f) => f.category === category), + })).filter((g) => g.items.length > 0); + + return ( + + + {grouped.map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + ))} + + + ); +} + +function WorkflowFileRow({ + item, + onView, + onDownload, +}: { + item: Freight.ClearanceWorkflowFile; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +}) { + const file = item.file; + if (!file) return null; + + const viewUrl = fileViewUrl(file.id); + const canPreview = isViewable({ name: file.name, url: viewUrl }); + + return ( + + + + + + + + + {item.label} + + + + {OWNER_LABELS[item.uploadedBy]} + + + {file.name} + + + + + + {canPreview ? ( + + + + ) : null} + {onDownload ? ( + + + + ) : null} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 7a3dd897e..0d7adc5be 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,6 +1,14 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Check, ShieldCheck } from "lucide-react"; -import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; +import { + Stack, + Group, + Text, + Badge, + Button, + Box, + Modal, +} from "@mantine/core"; import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; @@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({ contract, mutations, }: ContractApprovalStepsCardProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStep, setPendingStep] = + useState(null); + const steps = useMemo( () => [...(contract.approvalSteps ?? [])].sort( @@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + const openApprove = (step: Freight.IContractApprovalStep) => { + setPendingStep(step); + setConfirmOpen(true); + }; + + const closeApprove = () => { + setConfirmOpen(false); + setPendingStep(null); + }; + + const runApprove = () => { + if (!pendingStep) return; + mutations.approveStep.mutate( + { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, + { onSuccess: () => closeApprove() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({ : "Accept submission to begin"); return ( - - {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} - - } - > - - {subtitle} - - - {steps.length === 0 ? ( - - Use Accept for approval in staff actions to - instantiate steps. + <> + + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} - ) : ( - - {steps.map((step) => ( - - mutations.approveStep.mutate({ - stepId: step.id, - requiredRole: step.requiredRole, - }) - } - /> - ))} + + {steps.length === 0 ? ( + + Use Accept for approval in staff actions to + instantiate steps. + + ) : ( + + {steps.map((step) => ( + openApprove(step)} + /> + ))} + + )} + + + + + + You are about to approve the{" "} + + {pendingStep?.requiredRole} + {" "} + step for contract{" "} + + {contract.reference} + + . This action cannot be undone from this screen. + + + + + - )} - + + ); } 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 c660bb928..36b18bbec 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -54,6 +54,21 @@ export interface ContractClearanceReviewSectionProps { * by whom, when) but hide all approve / query / finalize actions. */ readOnly?: boolean; + /** + * Document approvals are locked (e.g. after all docs approved in phased flow) + * but queries remain available until {@link queriesLocked} or {@link readOnly}. + */ + approvalsLocked?: boolean; + /** + * Pre-clearance finalized — block opening new queries on customer documents. + */ + queriesLocked?: boolean; + /** + * ONE_TIME customs contracts use the phased milestone workflow. Hides the + * legacy "Finalize clearance" shortcut; booking readiness follows delivery + * order (import) or export release. + */ + phasedCustoms?: boolean; } const STATUS_META: Record< @@ -89,6 +104,9 @@ export function ContractClearanceReviewSection({ hideSummary, selfClear = false, readOnly = false, + phasedCustoms = false, + approvalsLocked = false, + queriesLocked = false, }: ContractClearanceReviewSectionProps) { const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); @@ -137,6 +155,11 @@ export function ContractClearanceReviewSection({ .filter((d) => d.file && d.reviewStatus !== "APPROVED") .map((d) => d.fileKey); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -170,14 +193,16 @@ export function ContractClearanceReviewSection({ subtitle={ readOnly ? `Reviewed by the ${reviewerTeam} team.` - : "Approve each document, or open a query to tell the customer what to fix." + : effectiveApprovalsLocked + ? "Documents are approved — you can still open a query if something needs fixing." + : "Approve each document, or open a query to tell the customer what to fix." } extra={ {stats.approved}/{stats.total} approved - {!readOnly && approvableKeys.length > 0 && ( + {!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && ( - + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + + )} ) : ( void; + reference: string; + message?: string; + confirmLabel?: string; +} + +export function ContractSignSuccessModal({ + opened, + onClose, + reference, + message = "The contract has been signed and recorded.", + confirmLabel = "Back to contract request", +}: ContractSignSuccessModalProps) { + return ( + + + + + + {reference} + + {message} + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx new file mode 100644 index 000000000..b33729bf1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { Ship, Upload } from "lucide-react"; +import toast from "react-hot-toast"; + +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; + +export type GlClearanceUploadKind = "do" | "ro"; + +export interface GlClearanceUploadModalProps { + opened: boolean; + kind: GlClearanceUploadKind | null; + onClose: () => void; + entityId: string; + isBooking: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + vesselDepartureDate?: string | null; + onSuccess?: () => void; + onPreview?: (file: { name: string; url: string }) => void; +} + +export function GlClearanceUploadModal({ + opened, + kind, + onClose, + entityId, + isBooking, + workflowFiles = [], + vesselDepartureDate, + onSuccess, + onPreview, +}: GlClearanceUploadModalProps) { + const [file, setFile] = useState(null); + const [vesselDate, setVesselDate] = useState( + vesselDepartureDate ? new Date(vesselDepartureDate) : null, + ); + const [loading, setLoading] = useState(false); + + const isDo = kind === "do"; + const isRo = kind === "ro"; + const replaceMode = isDo + ? Boolean(findWorkflowFile(workflowFiles, "delivery_order")) + : Boolean(findWorkflowFile(workflowFiles, "release_order")); + + const close = () => { + setFile(null); + onClose(); + }; + + const submit = async () => { + if (!file || !kind) return; + if (isRo && !vesselDate) { + toast.error("Vessel departure date is required."); + return; + } + + setLoading(true); + try { + if (isDo) { + if (isBooking) { + await bookingsService.uploadDeliveryOrder(entityId, file); + } else { + await contractsService.uploadDeliveryOrder(entityId, file); + } + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + } else { + const iso = vesselDate!.toISOString().slice(0, 10); + const result = isBooking + ? await bookingsService.uploadReleaseOrder(entityId, file, iso) + : await contractsService.uploadReleaseOrder(entityId, file, iso); + if (result.hold) { + toast.error(result.holdReason ?? "Vessel date too soon"); + } else { + toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); + } + } + setFile(null); + onSuccess?.(); + close(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + {isDo ? "Upload Delivery Order" : "Upload Release Order"} + + } + radius="md" + size="md" + > + + + {isDo + ? "Upload the Djibouti Delivery Order (DO) for this import shipment." + : "Upload the Release Order and confirm the vessel departure date."} + + + {isRo ? ( + setVesselDate(v ? new Date(v) : null)} + size="sm" + required + /> + ) : null} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 676e4840e..f9bd7683d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, @@ -6,14 +6,11 @@ import { } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { - ActionIcon, Alert, - Badge, Box, Button, Center, Divider, - Grid, Group, Loader, Modal, @@ -28,13 +25,13 @@ import { } from "@mantine/core"; import { AlertCircle, + CalendarDays, CheckCircle2, - Container as ContainerIcon, + ChevronLeft, FileText, + MapPin, Package, - Plus, Receipt, - Trash2, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -43,19 +40,23 @@ import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { contractsService } from "@/services/contracts.service"; import { - useContractCapacity, useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; -import { Boxes } from "lucide-react"; import { computeGlShipmentTotal, formatRateUnit, type GlShipmentQuantities, } from "./gl-booking-form/total"; +import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; +import { + fieldStyles, + StepCard, + StepHeader, + StepLabel, +} from "./gl-booking-form/form-ui"; interface UnitDraft { containerNumber: string; @@ -75,22 +76,28 @@ interface BulkLineDraft { cargoWeightTons: number | string; itemCount: number | string; hazardousQuantity: number | string; + reeferQuantity: number | string; } function emptyUnit(): UnitDraft { return { containerNumber: "", sealNumber: "", vgmTons: "" }; } +function bulkUnitOfMeasure( + contract: Freight.IContract, +): "PER_TON" | "PER_ITEM" { + const hasPerItem = contract.pricingBreakdown?.lineItems?.some( + (li) => li.unit === "per_item", + ); + return hasPerItem ? "PER_ITEM" : "PER_TON"; +} + export default function GlCreateBookingForm() { const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); - // When GL accepts a shipment request, the form opens with ?requestId=… so it - // can prefill the requested quantities/date and mark the request accepted on - // success. const requestId = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); - const { data: capacity = [] } = useContractCapacity(id); const mutations = useContractMutations(id ?? ""); const { data: bookingRequest } = useQuery({ @@ -105,42 +112,8 @@ export default function GlCreateBookingForm() { const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); const [prefilled, setPrefilled] = useState(false); - - // Prefill once from an accepted shipment request: size/qty container lines - // (one blank unit per requested container) + bulk + route + notes. GL still - // enters per-unit container numbers + sets the binding shipment date. - useEffect(() => { - if (!bookingRequest || prefilled) return; - setPrefilled(true); - const lines = bookingRequest.requestedLines ?? {}; - if (lines.containers?.length) { - setContainerLines( - lines.containers.map((c) => ({ - containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), - })), - ); - } else if (lines.bulk) { - setBulkLines([ - { - cargoTypeId: lines.bulk.cargoTypeId ?? "", - cargoWeightTons: lines.bulk.cargoWeightTons ?? "", - itemCount: lines.bulk.itemCount ?? "", - hazardousQuantity: lines.bulk.hazardousQuantity ?? "", - }, - ]); - } - if (bookingRequest.contractRouteId) - setContractRouteId(bookingRequest.contractRouteId); - if (bookingRequest.notes) setNotes(bookingRequest.notes); - }, [bookingRequest, prefilled]); - // Price-confirm modal — GL reviews the estimate before booking on behalf of - // the customer, mirroring the portal customer flow. const [priceOpen, setPriceOpen] = useState(false); + const seededRef = useRef(false); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -158,7 +131,6 @@ export default function GlCreateBookingForm() { return [...sizes]; }, [contract?.cargoScope]); - // Bulk cargo types declared on the contract scope (prefill, no free-text). const bulkCargoOptions = useMemo(() => { const seen = new Map(); (contract?.cargoScope ?? []).forEach((s) => { @@ -172,11 +144,73 @@ export default function GlCreateBookingForm() { const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; - // Normalized quantities for the client-side price estimate (same source the - // portal customer sees: the contract's frozen unit rates × entered qty). + useEffect(() => { + if (!bookingRequest || prefilled) return; + setPrefilled(true); + const lines = bookingRequest.requestedLines ?? {}; + if (lines.containers?.length) { + setContainerLines( + lines.containers.map((c) => ({ + containerSize: c.containerSize, + hazardousQuantity: c.hazardousQuantity ?? "0", + reeferQuantity: c.reeferQuantity ?? "", + units: Array.from({ length: Math.max(1, c.quantity) }, () => + emptyUnit(), + ), + })), + ); + } else if (lines.bulk) { + setBulkLines([ + { + cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId, + cargoWeightTons: lines.bulk.cargoWeightTons ?? "", + itemCount: lines.bulk.itemCount ?? "", + hazardousQuantity: lines.bulk.hazardousQuantity ?? "0", + reeferQuantity: "", + }, + ]); + } + if (bookingRequest.contractRouteId) + setContractRouteId(bookingRequest.contractRouteId); + if (bookingRequest.notes) setNotes(bookingRequest.notes); + }, [bookingRequest, prefilled, defaultBulkCargoTypeId]); + + useEffect(() => { + if (!contract || prefilled || seededRef.current) return; + seededRef.current = true; + if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { + setContainerLines( + containerSizes.map((size) => ({ + containerSize: size, + hazardousQuantity: "0", + reeferQuantity: "0", + units: [emptyUnit()], + })), + ); + } else if (!isContainer && bulkLines.length === 0) { + setBulkLines([ + { + cargoTypeId: defaultBulkCargoTypeId, + cargoWeightTons: "", + itemCount: "", + hazardousQuantity: "0", + reeferQuantity: "0", + }, + ]); + } + }, [ + contract, + prefilled, + isContainer, + containerSizes, + containerLines.length, + bulkLines.length, + defaultBulkCargoTypeId, + ]); + const quantities: GlShipmentQuantities = useMemo( () => ({ - isContainer, + isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, @@ -200,17 +234,11 @@ export default function GlCreateBookingForm() { [contract, quantities], ); - // The route this shipment ships on (for the cargo-aware day list). For a - // single-route contract there's exactly one; for GENERAL multi-route, the - // selected route (defaults to the first). const selectedRoute = useMemo( () => routes.find((r) => r.id === contractRouteId) ?? routes[0], [routes, contractRouteId], ); - // Cargo-aware availability query: only days where a train has remaining - // capacity AND enough matching-type wagons for the entered cargo. Null until - // the cargo is entered (so the Schedule section stays empty first). const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; @@ -247,18 +275,113 @@ export default function GlCreateBookingForm() { const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ - input: cargoQuery ?? { - freightType: "BULK" as const, - }, + input: cargoQuery ?? { freightType: "BULK" as const }, }), enabled: cargoQuery !== null, }); + const syncUnits = (lineIdx: number, qty: number) => { + setContainerLines((prev) => + prev.map((line, i) => { + if (i !== lineIdx) return line; + const next = [...line.units]; + while (next.length < qty) next.push(emptyUnit()); + next.length = Math.max(0, qty); + return { ...line, units: next }; + }), + ); + }; + + const patchLine = (idx: number, patch: Partial) => + setContainerLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const patchUnit = ( + lineIdx: number, + unitIdx: number, + patch: Partial, + ) => + patchLine(lineIdx, { + units: containerLines[lineIdx].units.map((u, i) => + i === unitIdx ? { ...u, ...patch } : u, + ), + }); + + const patchBulk = (idx: number, patch: Partial) => + setBulkLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const canSubmit = + Boolean(scheduledDate) && + (!needsRouteSelect || Boolean(contractRouteId)) && + (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); + + const handleSubmit = () => { + if (!scheduledDate || !contract) return; + + const payload: Freight.CreateBookingUnderContractDto = { + scheduledDate, + ...(contractRouteId ? { contractRouteId } : {}), + ...(notes.trim() ? { notes: notes.trim() } : {}), + }; + + if (isContainer) { + payload.containers = containerLines + .filter((l) => l.units.length > 0) + .map((l) => ({ + containerSize: l.containerSize, + quantity: l.units.length, + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + units: l.units.map((u) => ({ + containerNumber: u.containerNumber, + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + })), + })); + } else { + payload.bulkLines = bulkLines.map((l) => ({ + ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), + ...(l.cargoWeightTons !== "" + ? { cargoWeightTons: Number(l.cargoWeightTons) } + : {}), + ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + })); + } + + mutations.createBooking.mutate(payload, { + onSuccess: async (booking) => { + if (requestId) { + try { + await contractsService.acceptBookingRequest(requestId, booking.id); + } catch { + // Non-fatal + } + navigate(`/dashboard/bookings/${booking.id}/clearance`); + } else { + navigate(`/dashboard/contracts/clearance/${contract.id}`); + } + }, + }); + }; + if (isLoading) { return (
- +
); @@ -275,194 +398,74 @@ export default function GlCreateBookingForm() { ); } - // ── Container line helpers ── - const addContainerLine = () => - setContainerLines((prev) => [ - ...prev, - { - containerSize: containerSizes[0] ?? "20ft", - hazardousQuantity: "", - reeferQuantity: "", - units: [emptyUnit()], - }, - ]); - const removeContainerLine = (idx: number) => - setContainerLines((prev) => prev.filter((_, i) => i !== idx)); - const patchLine = (idx: number, patch: Partial) => - setContainerLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - const addUnit = (lineIdx: number) => - patchLine(lineIdx, { - units: [...containerLines[lineIdx].units, emptyUnit()], - }); - const removeUnit = (lineIdx: number, unitIdx: number) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx), - }); - const patchUnit = ( - lineIdx: number, - unitIdx: number, - patch: Partial, - ) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.map((u, i) => - i === unitIdx ? { ...u, ...patch } : u, - ), - }); - - // ── Bulk line helpers ── - const addBulkLine = () => - setBulkLines((prev) => [ - ...prev, - { - cargoTypeId: defaultBulkCargoTypeId, - cargoWeightTons: "", - itemCount: "", - hazardousQuantity: "", - }, - ]); - const removeBulkLine = (idx: number) => - setBulkLines((prev) => prev.filter((_, i) => i !== idx)); - const patchBulk = (idx: number, patch: Partial) => - setBulkLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - - const canSubmit = - Boolean(scheduledDate) && - (!needsRouteSelect || Boolean(contractRouteId)) && - (isContainer ? containerLines.length > 0 : bulkLines.length > 0); - - const handleSubmit = () => { - if (!scheduledDate) return; - - const payload: Freight.CreateBookingUnderContractDto = { - scheduledDate, - ...(contractRouteId ? { contractRouteId } : {}), - ...(notes.trim() ? { notes: notes.trim() } : {}), - }; - - if (isContainer) { - payload.containers = containerLines.map((l) => ({ - containerSize: l.containerSize, - quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), - units: l.units.map((u) => ({ - containerNumber: u.containerNumber, - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), - vgmTons: Number(u.vgmTons) || 0, - })), - })); - } else { - payload.bulkLines = bulkLines.map((l) => ({ - ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), - ...(l.cargoWeightTons !== "" - ? { cargoWeightTons: Number(l.cargoWeightTons) } - : {}), - ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - })); - } - - mutations.createBooking.mutate(payload, { - onSuccess: async (booking) => { - if (requestId) { - // GENERAL+customs accept flow: mark the request accepted + link the - // booking, then hand off to the per-booking clearance review. - try { - await contractsService.acceptBookingRequest(requestId, booking.id); - } catch { - // Non-fatal — the booking exists; the request link can be retried. - } - navigate(`/dashboard/clearance/${booking.id}`); - } else { - navigate(`/dashboard/bookings/${booking.id}/milestones`); - } - }, - }); - }; + const bulkUom = bulkUnitOfMeasure(contract); return ( - + + + + New Shipment Booking + + + Book a shipment on behalf of the customer for contract {contract.reference}. + + + + - - {capacity.length > 0 && ( - c.remaining === 0) ? "red" : "blue"} - variant="light" - radius="md" - icon={} - title="Contract draw-down capacity" - > - - {capacity.map((c, i) => ( - - {c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left - - ))} - - - )} - {bookingRequest ? ( - } - title="From shipment request" - > - Booking on behalf of the customer for request{" "} - {bookingRequest.reference}. - {bookingRequest.scheduledDate ? ( - <> - {" "} - Customer requested{" "} - - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))} - {" "} - — set the binding shipment date below. - - ) : null} - - ) : null} - + {bookingRequest ? ( + } + title="From shipment request" + mb="lg" + > + Booking on behalf of the customer for request{" "} + {bookingRequest.reference}. + {bookingRequest.scheduledDate ? ( + <> + {" "} + Customer requested{" "} + + {new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(bookingRequest.scheduledDate))} + {" "} + — set the binding shipment date below. + + ) : null} + + ) : null} + + + + } + title="Route" + description={ + needsRouteSelect + ? "Choose which contracted route this shipment ships on." + : "This shipment ships on the contract's only route." + } + /> {needsRouteSelect ? ( - patchLine(lineIdx, { - containerSize: v ?? line.containerSize, - }) - } - data={ - containerSizes.length > 0 - ? containerSizes - : ["20ft", "40ft"] - } - /> - - + + {line.containerSize} containers + + + syncUnits(lineIdx, Number(v) || 0)} + radius={10} + styles={fieldStyles} + /> + {contract.isHazardous ? ( patchLine(lineIdx, { hazardousQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - + ) : null} + {contract.isReefer ? ( patchLine(lineIdx, { reeferQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - - - - - - {line.units.map((unit, unitIdx) => ( - - - - patchUnit(lineIdx, unitIdx, { - containerNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { - sealNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { vgmTons: v }) - } - /> - - - removeUnit(lineIdx, unitIdx)} - aria-label="Remove unit" - > - - - - - ))} - - -
- ))} - - )} - - ) : ( - } - onClick={addBulkLine} - > - Add line - - } - > - {bulkLines.length === 0 ? ( - - Add at least one bulk line. - - ) : ( - - {bulkLines.map((line, idx) => ( - - - - Line {idx + 1} - - removeBulkLine(idx)} - aria-label="Remove line" - > - - + ) : null} - - - {bulkCargoOptions.length > 0 ? ( - patchBulk(idx, { cargoTypeId: v ?? "" })} + data={bulkCargoOptions} + radius={10} + styles={fieldStyles} + /> + ) : null} + {bulkUom === "PER_TON" ? ( + patchBulk(idx, { cargoWeightTons: v })} + radius={10} + styles={fieldStyles} + /> + ) : ( + patchBulk(idx, { itemCount: v })} + radius={10} + styles={fieldStyles} + /> + )} + {contract.isHazardous ? ( + patchBulk(idx, { hazardousQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + {contract.isReefer ? ( + patchBulk(idx, { reeferQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + + ))} + + )} - + + } + title="Schedule" + description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." + /> {cargoQuery === null ? ( } > - Enter the cargo details first — available shipment days depend on - the wagons the cargo needs. + Enter your cargo details first — available shipment days depend on + the wagons your cargo needs. ) : ( - <> - {bookingRequest?.scheduledDate ? ( - - Customer requested{" "} - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))}{" "} - — pick the binding shipment day below. - - ) : null} - - + + Shipment day * + + + + )} - + - +