diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b24cf6c83..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", 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-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 9a441ab65..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,5 @@ import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; @@ -8,6 +8,7 @@ import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") @FreightAdmin() +@ApiBearerAuth() export class BillingController { constructor(private readonly billingService: BillingService) { } 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 443f511ee..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, @@ -285,20 +284,15 @@ 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). */ @@ -344,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); } @@ -422,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), + }; + }); } /** @@ -450,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, @@ -465,62 +528,71 @@ export class BillingService { ); } - 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 }, { - paidAmount, - balanceAmount, - status, - payments, - paidAt: fullyPaid ? at : (invoice.paidAt ?? null), - } as never); + const patch = { + paidAmount, + balanceAmount, + status, + payments, + 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, @@ -532,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, @@ -549,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, @@ -566,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. */ @@ -600,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, @@ -620,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" }, @@ -628,56 +726,24 @@ 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. - * - * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) - * to enlist in its DB transaction. - */ - async refundPayable( - source: Freight.InvoiceSource, - sourceId: string, - manager?: EntityManager, - ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, - order: { issuedAt: "DESC" }, - }); - if (!invoice) return null; - - return this.markInvoiceAsRefunded(invoice.id, mg); } /** @@ -693,11 +759,17 @@ export class BillingService { 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: In(OPEN_STATUSES) }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; @@ -721,17 +793,29 @@ export class BillingService { 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) }, + 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, @@ -739,29 +823,33 @@ export class BillingService { ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { id: invoiceId, status: In(OPEN_STATUSES) }, - order: { issuedAt: "DESC" }, + where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, }); if (!invoice) return; - await mg.update(Invoice, { id: invoice.id }, { status }); + 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"; @@ -770,15 +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}`, + `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 @@ -787,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", @@ -822,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) }, @@ -831,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/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index c70133ee7..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,7 +1,13 @@ -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; import { BillingService, @@ -58,9 +64,9 @@ 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, @@ -74,8 +80,8 @@ export class BookingInvoiceService { 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.`, ); } @@ -102,7 +108,13 @@ export class BookingInvoiceService { } } - updateStatus = this.billing.updateStatus; + 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 @@ -165,7 +177,11 @@ 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) throw new Error("No price"); + 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", 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 90ccd0118..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 @@ -7,28 +7,30 @@ import { } 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"; -import { eatDay } from "../train-scheduling/batch-window.util"; -import { isRoadService } from "./road.util"; -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 { BookingPricingService } from "./booking-pricing.service"; -import { BookingsRepository } from "./bookings.repository"; -import { assertBookingStatus } from "./booking-status.util"; -import { clearanceCodesForBooking } from "./clearance.util"; -import { - computeNextStep, - type BookingNextStep, -} from "./booking-next-step.util"; -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 { BookingInvoiceService } from "./booking-invoice.service"; +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { isRoadService } from './road.util'; +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 { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { clearanceCodesForBooking } from './clearance.util'; +import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +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 { @@ -44,8 +46,17 @@ export class BookingTransitionService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, - private readonly invoiceService: BookingInvoiceService, - ) { } + @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); @@ -447,6 +458,7 @@ export class BookingTransitionService { "CHANGES_REQUESTED", "PENDING_APPROVAL", "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( @@ -510,8 +522,19 @@ export class BookingTransitionService { 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); @@ -667,6 +690,18 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { 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); } @@ -734,6 +769,15 @@ export class BookingTransitionService { "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( bookingId, @@ -750,8 +794,30 @@ export class BookingTransitionService { "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.). */ @@ -787,7 +853,12 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); + 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) { 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 9548eee88..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,14 +12,15 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, 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 { BookingStaff } from "../../common/booking-guards"; -import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; +} from '@nestjs/common'; +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, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -30,17 +31,22 @@ import { } 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 { BookingReferenceDataService } from "./booking-reference-data.service"; -import { BookingsService } from "./bookings.service"; -import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; -import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; +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'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, ApproveStepDto, @@ -52,10 +58,11 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from "./dto/request-changes.dto"; -import { ContractViewDto } from "./dto/contract-view.dto"; -import { SignContractDto } from "./dto/sign-contract.dto"; -import { UpdateBookingDto } from "./dto/update-booking.dto"; +} 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, @@ -75,7 +82,8 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, - ) { } + private readonly bookingClearanceService: BookingClearanceService, + ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) @@ -268,7 +276,40 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(":id/tracking") + @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", description: @@ -358,7 +399,21 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get(":id/clearance") + @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)", @@ -469,7 +524,161 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(":id/staff/request-changes") + @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" }) async requestChanges( 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 4cd5d10df..2cc23b3fa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -4,41 +4,43 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; -import { CompaniesModule } from "../companies/companies.module"; -import { FilesModule } from "../files/files.module"; -import { MinioModule } from "../minio/minio.module"; -import { RuleEngineModule } from "../rule-engine/rule-engine.module"; -import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; -import { SignaturesModule } from "../signatures/signatures.module"; -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 { 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 { BookingsRepository } from "./bookings.repository"; -import { ConsolidationService } from "./consolidation.service"; -import { BookingsService } from "./bookings.service"; -import { BookingApprovalStep } from "./entities/booking-approval-step.entity"; -import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity"; -import { BookingDocumentReview } from "./entities/booking-document-review.entity"; -import { BookingContainer } from "./entities/booking-container.entity"; -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 { CompaniesModule } from '../companies/companies.module'; +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; +import { SignaturesModule } from '../signatures/signatures.module'; +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 { 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 { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { BookingsService } from './bookings.service'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingDocumentReview } from './entities/booking-document-review.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +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 { 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"; -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"; + @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, @@ -69,7 +73,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController, BookingPaymentController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, @@ -79,7 +83,6 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu BookingTransitionService, BookingContractService, BookingInvoiceService, - BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, 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.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 112c044fc..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,24 +4,28 @@ import { Logger, NotFoundException, OnModuleInit, -} from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { Cron, SchedulerRegistry } from "@nestjs/schedule"; -import { DataSource } from "typeorm"; -import { Freight } from "@edr/types"; + Optional, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +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'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +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 { Booking } from "../bookings/entities/booking.entity"; -import { BookingsRepository } from "../bookings/bookings.repository"; -import { Locomotive } from "../locomotives/entities/locomotive.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"; -import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository"; -import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity"; -import { BookingNotifierService } from "./booking-notifier.service"; -import { TrainSchedulingService } from "./train-scheduling.service"; -import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util"; + + import { BATCH_CRON, BATCH_TIMEZONE, @@ -34,8 +38,9 @@ import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, -} from "./train-capacity.util"; -import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +} 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 { @@ -182,7 +187,10 @@ export class BookingBatchService implements OnModuleInit { 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. */ async onModuleInit(): Promise { @@ -578,7 +586,7 @@ 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, @@ -662,7 +670,7 @@ 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, @@ -1069,6 +1077,7 @@ export class BookingBatchService implements OnModuleInit { Freight.InvoiceSource.Booking, booking.id, deadline, + "PREPAID", ); await this.notifier.payNow(booking, deadline); } @@ -1101,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). + } } /** @@ -1120,7 +1139,7 @@ export class BookingBatchService implements OnModuleInit { // 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); + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); } @@ -1167,6 +1186,7 @@ export class BookingBatchService implements OnModuleInit { await this.billing.expirePayable( Freight.InvoiceSource.Booking, victim.id, + "PREPAID", manager, ); }); 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 0a56865d5..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 @@ -26,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: [ @@ -51,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/cmds/index.ts b/apps/edr-freight-api/src/scripts/cmds/index.ts deleted file mode 100644 index 810945bbb..000000000 --- a/apps/edr-freight-api/src/scripts/cmds/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type Vorpal from "vorpal"; -import type { CommandContext } from "./types"; - -import { registerSeedTestContracts } from "./seed-test-contracts.cmd"; -import { registerSeedTestSchedules } from "./seed-test-schedules.cmd"; -import { registerSeedTestCompany } from "./seed-test-company.cmd"; - -export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void { - registerSeedTestContracts(vorpal, ctx); - registerSeedTestSchedules(vorpal, ctx); - registerSeedTestCompany(vorpal, ctx); -} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts deleted file mode 100644 index bef4ed031..000000000 --- a/apps/edr-freight-api/src/scripts/cmds/seed-test-company.cmd.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type Vorpal from "vorpal"; -import { DataSource } from "typeorm"; -import type { CommandContext } from "./types"; -import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; -import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; -import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; - -export function registerSeedTestCompany( - vorpal: Vorpal, - ctx: CommandContext, -): void { - vorpal - .command("seed:test-company", "Generate a test company with approved importer/exporter profiles and an external user") - .option("--name ", "Company name (default: Test Company)") - .option("--email ", "Company email (default: company@test.com)") - .option("--tin ", "Tax ID (default: auto-generated TSTxxxxx)") - .action(async function (this: any, args: any) { - const { app } = ctx; - const ds = app.get(DataSource); - - const raw = await ds.query( - `SELECT "tin" FROM "freight"."companies" WHERE "tin" LIKE 'TST%' AND "deleted_at" IS NULL ORDER BY "tin" DESC LIMIT 1`, - ); - let nextTinNum = 1; - if (raw.length > 0) { - const num = parseInt((raw[0] as any).tin.replace("TST", ""), 10); - if (!isNaN(num)) nextTinNum = num + 1; - } - - const name = args.options?.name ?? "Test Company"; - const email = args.options?.email ?? "company@test.com"; - const tin = args.options?.tin ?? `TST${String(nextTinNum).padStart(6, "0")}`; - const userId = `ffffffff-0000-4000-8000-${String(nextTinNum).padStart(12, "0")}`; - - const existing = await ds.getRepository(Company).findOne({ where: { tin } }); - if (existing) { - this.log(`Company with TIN ${tin} already exists (${existing.name})`); - return; - } - - const company = await ds.getRepository(Company).save( - ds.getRepository(Company).create({ - name, - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin, - country: "Ethiopia", - nationality: CompanyNationality.Ethiopian, - email, - phone: "+251911000000", - address: "Test Address", - }), - ); - this.log(` Created company: ${company.name} (TIN: ${tin})`); - - for (const type of [ProfileType.importer, ProfileType.exporter]) { - await ds.getRepository(CompanyProfile).save( - ds.getRepository(CompanyProfile).create({ - companyId: company.id, - type, - reference: `TST-${type.toUpperCase()}-${String(nextTinNum).padStart(3, "0")}`, - status: ProfileStatus.Active, - }), - ); - this.log(` Created ${type} profile (approved)`); - } - - await ds.getRepository(ExternalProfile).save( - ds.getRepository(ExternalProfile).create({ - userId, - companyId: company.id, - firstName: "Test", - lastName: "User", - isPrimaryContact: true, - activeProfileType: ProfileType.importer, - onboardingCompleted: true, - onboardingStep: "done", - }), - ); - this.log(` Created external profile: Test User (userId: ${userId})`); - - this.log(`\nDone — login with email "${email}" and password "password"`); - }); -} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts deleted file mode 100644 index a46a9dee8..000000000 --- a/apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts +++ /dev/null @@ -1,330 +0,0 @@ -import type Vorpal from "vorpal"; -import { DataSource } from "typeorm"; -import type { CommandContext } from "./types"; -import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity"; -import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity"; -import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity"; -import { Yard } from "../../modules/rule-engine/entities/yard.entity"; -import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity"; -import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity"; -import { Rate } from "../../modules/rule-engine/entities/rate.entity"; -import { Contract } from "../../modules/contracts/entities/contract.entity"; -import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity"; -import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity"; -import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity"; - -export function registerSeedTestContracts( - vorpal: Vorpal, - ctx: CommandContext, -): void { - vorpal - .command("seed:test-contracts", "Generate test contracts with companies and all deps") - .option("-n, --count ", "Number of contracts to create (default: 4)") - .option("--status ", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)") - .option("--freight ", "Freight types: CONTAINER,BULK (default: both)") - .option("--direction ", "Trade directions: IMPORT,EXPORT (default: both)") - .option("--company ", "Only create contracts for company matching name/TIN") - .action(async function (this: any, args: any) { - const { app } = ctx; - const ds = app.get(DataSource); - - const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10))); - const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE") - .split(",").map((s: string) => s.trim()).filter(Boolean); - const freightList = (args.options?.freight ?? "CONTAINER,BULK") - .split(",").map((s: string) => s.toUpperCase().trim()) - .filter((s: string) => s === "CONTAINER" || s === "BULK"); - const directionList = (args.options?.direction ?? "IMPORT,EXPORT") - .split(",").map((s: string) => s.toUpperCase().trim()) - .filter((s: string) => s === "IMPORT" || s === "EXPORT"); - const companyFilter = args.options?.company as string | undefined; - - if (freightList.length === 0 || directionList.length === 0) { - this.log("error: at least one freight type and trade direction required"); - return; - } - - this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`); - - const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); - const yardByCode = new Map(yards.map((y) => [y.code, y])); - const djibouti = yardByCode.get("DJIBOUTI"); - const addis = yardByCode.get("ADDIS_ABABA"); - if (!djibouti || !addis) { - this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded"); - return; - } - - const serviceTypes = await ds - .getRepository(ServiceType) - .find({ where: { isActive: true } }); - const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); - const railContainer = stByCode.get("RAIL_CONTAINER"); - const railBulk = stByCode.get("RAIL_BULK"); - if (!railContainer && !railBulk) { - this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded"); - return; - } - - const cargoTypes = await ds - .getRepository(CargoType) - .find({ where: { isActive: true } }); - const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); - const grain = cargoByCode.get("GRAIN"); - const sugar = cargoByCode.get("SUGAR"); - const fertilizer = cargoByCode.get("FERTILIZER"); - - const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } }); - - const companyRepo = ds.getRepository(Company); - let companies = await companyRepo.find({}); - - if (companyFilter) { - companies = companies.filter( - (c) => - c.name.toLowerCase().includes(companyFilter.toLowerCase()) || - c.tin.includes(companyFilter), - ); - } - - if (companies.length === 0) { - this.log("No existing companies found — seeding test companies..."); - companies = await seedTestCompanies(ds, (msg) => this.log(msg)); - } else { - this.log(`Using ${companies.length} existing companies from DB`); - } - - const contractRepo = ds.getRepository(Contract); - - const maxRaw = await ds.query( - `SELECT "reference" FROM "freight"."contracts" WHERE "reference" LIKE 'TST-CTR-%' AND "deleted_at" IS NULL ORDER BY "reference" DESC LIMIT 1`, - ); - let nextRef = 1; - if (maxRaw.length > 0) { - const num = parseInt(maxRaw[0].reference.replace("TST-CTR-", ""), 10); - if (!isNaN(num)) nextRef = num + 1; - } - - for (let i = 0; i < count; i++) { - const statusIdx = i % statusList.length; - const ftIdx = i % freightList.length; - const dirIdx = i % directionList.length; - const companyIdx = i % companies.length; - - const status = statusList[statusIdx]; - const freightType = freightList[ftIdx]; - const direction = directionList[dirIdx]; - const company = companies[companyIdx]; - - const profile = await ds.getRepository(CompanyProfile).findOne({ - where: { - companyId: company.id, - type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter, - }, - }); - if (!profile) continue; - - const ref = `TST-CTR-${String(nextRef + i).padStart(5, "0")}`; - - const serviceTypeId = - freightType === "BULK" && railBulk - ? railBulk.id - : railContainer - ? railContainer.id - : serviceTypes[0].id; - - const originId = direction === "IMPORT" ? djibouti.id : addis.id; - const destId = direction === "IMPORT" ? addis.id : djibouti.id; - - const contract = contractRepo.create({ - reference: ref, - companyId: company.id, - companyProfileId: profile.id, - contractKind: "ONE_TIME" as const, - tradeDirection: direction, - freightType, - serviceTypeId, - paymentCurrency: "USD", - customsClearingEnabled: false, - equipmentReturn: "without_return", - status, - versionNumber: 1, - }); - - const saved = await contractRepo.save(contract); - - await ds.getRepository(ContractRoute).save( - ds.getRepository(ContractRoute).create({ - contractId: saved.id, - originYardId: originId, - destinationYardId: destId, - sortOrder: 1, - }), - ); - - if (freightType === "CONTAINER") { - for (const size of ["20FT", "40FT"] as const) { - await ds.getRepository(ContractCargoScope).save( - ds.getRepository(ContractCargoScope).create({ - contractId: saved.id, - containerSize: size, - }), - ); - } - } else { - const bulkCargo = grain || sugar || fertilizer; - if (bulkCargo) { - await ds.getRepository(ContractCargoScope).save( - ds.getRepository(ContractCargoScope).create({ - contractId: saved.id, - cargoTypeId: bulkCargo.id, - quantityCap: 10000, - }), - ); - } - } - - const matchingRates = rates.filter((r) => { - if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false; - if (r.appliesTo === "BULK" && freightType !== "BULK") return false; - if (r.tradeDirection && r.tradeDirection !== direction) return false; - return r.status === "LIVE" && r.trigger === "ALWAYS"; - }); - - const seen = new Set(); - for (const rate of matchingRates.slice(0, 3)) { - const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`; - if (seen.has(sig)) continue; - seen.add(sig); - - await ds.getRepository(ContractRateSnapshot).save( - ds.getRepository(ContractRateSnapshot).create({ - contractId: saved.id, - rateId: rate.id, - rateCode: rate.rateType, - unitPrice: Number(rate.rateValue), - unitOfMeasure: rate.rateUnit, - currency: rate.currency ?? "USD", - containerSize: freightType === "CONTAINER" ? "20FT" : null, - isSurcharge: rate.trigger !== "ALWAYS", - conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null, - }), - ); - } - - this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`); - } - - this.log(`Done — ${count} new contracts created`); - }); -} - -interface CompanySeed { - name: string; - tin: string; - profiles: Array<{ type: ProfileType; reference: string }>; - externalProfile: { userId: string; firstName: string; lastName: string }; -} - -const TEST_COMPANIES: CompanySeed[] = [ - { - name: "Test Importer Co.", tin: "TST000001", - profiles: [ - { type: ProfileType.importer, reference: "TST-IM-001" }, - { type: ProfileType.exporter, reference: "TST-EX-001" }, - ], - externalProfile: { userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" }, - }, - { - name: "Test Exporter Ltd.", tin: "TST000002", - profiles: [ - { type: ProfileType.importer, reference: "TST-IM-002" }, - { type: ProfileType.exporter, reference: "TST-EX-002" }, - ], - externalProfile: { userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" }, - }, - { - name: "Bulk Commodities PLC", tin: "TST000003", - profiles: [ - { type: ProfileType.importer, reference: "TST-IM-003" }, - { type: ProfileType.exporter, reference: "TST-EX-003" }, - ], - externalProfile: { userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" }, - }, - { - name: "Hazardous Logistics Inc.", tin: "TST000004", - profiles: [ - { type: ProfileType.importer, reference: "TST-IM-004" }, - { type: ProfileType.exporter, reference: "TST-EX-004" }, - ], - externalProfile: { userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" }, - }, -]; - -async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise { - const companyRepo = ds.getRepository(Company); - const profileRepo = ds.getRepository(CompanyProfile); - const extProfileRepo = ds.getRepository(ExternalProfile); - const result: Company[] = []; - - for (const seed of TEST_COMPANIES) { - let company = await companyRepo.findOne({ where: { tin: seed.tin } }); - if (!company) { - company = await companyRepo.save( - companyRepo.create({ - name: seed.name, - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin: seed.tin, - country: "Ethiopia", - nationality: CompanyNationality.Ethiopian, - email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`, - phone: "+251911000001", - }), - ); - log(` Created company: ${seed.name}`); - } else { - log(` Company already exists: ${seed.name}`); - } - - for (const p of seed.profiles) { - const existing = await profileRepo.findOne({ - where: { companyId: company.id, type: p.type }, - }); - if (!existing) { - await profileRepo.save( - profileRepo.create({ - companyId: company.id, - type: p.type, - reference: p.reference, - status: ProfileStatus.Active, - }), - ); - log(` Created ${p.type} profile: ${p.reference}`); - } - } - - const ext = seed.externalProfile; - const existingExt = await extProfileRepo.findOne({ - where: { companyId: company.id, userId: ext.userId }, - }); - if (!existingExt) { - await extProfileRepo.save( - extProfileRepo.create({ - userId: ext.userId, - companyId: company.id, - firstName: ext.firstName, - lastName: ext.lastName, - isPrimaryContact: true, - onboardingCompleted: true, - }), - ); - log(` Created external profile: ${ext.firstName} ${ext.lastName}`); - } - - result.push(company); - } - - return result; -} diff --git a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts b/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts deleted file mode 100644 index eeab0e2dd..000000000 --- a/apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type Vorpal from "vorpal"; -import { DataSource } from "typeorm"; -import { WagonStatus } from "@edr/types"; -import type { CommandContext } from "./types"; -import { Yard } from "../../modules/rule-engine/entities/yard.entity"; -import { Route } from "../../modules/routes/entities/route.entity"; -import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity"; -import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity"; -import { Wagon } from "../../modules/wagons/entities/wagon.entity"; -import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity"; -import { TrainSet } from "../../modules/train-sets/entities/train-set.entity"; -import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity"; -import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity"; -import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity"; - -async function nextSequence(ds: DataSource, pattern: string): Promise { - const like = pattern.replace(/\*/g, "%"); - const raw = await ds.query( - `SELECT "train_number" FROM "freight"."train_schedules" WHERE "train_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "train_number" DESC LIMIT 1`, - [like.replace(/%/g, "") + "%"], - ); - if (raw.length === 0) return 1; - const ref: string = raw[0].train_number; - const num = parseInt(ref.replace(pattern.split("*")[0], ""), 10); - return isNaN(num) ? 1 : num + 1; -} - -async function nextRouteSeq(ds: DataSource, prefix: string): Promise { - const raw = await ds.query( - `SELECT "name" FROM "freight"."routes" WHERE "name" LIKE $1 AND "deleted_at" IS NULL ORDER BY "name" DESC LIMIT 1`, - [prefix + "%"], - ); - if (raw.length === 0) return 1; - const num = parseInt(raw[0].name.replace(prefix, ""), 10); - return isNaN(num) ? 1 : num + 1; -} - -async function nextWagonSeq(ds: DataSource, prefix: string): Promise { - const raw = await ds.query( - `SELECT "wagon_number" FROM "freight"."wagons" WHERE "wagon_number" LIKE $1 AND "deleted_at" IS NULL ORDER BY "wagon_number" DESC LIMIT 1`, - [prefix + "%"], - ); - if (raw.length === 0) return 1; - const num = parseInt(raw[0].wagon_number.replace(prefix, ""), 10); - return isNaN(num) ? 1 : num + 1; -} - -export function registerSeedTestSchedules( - vorpal: Vorpal, - ctx: CommandContext, -): void { - vorpal - .command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking") - .option("-n, --count ", "Number of schedules to create (default: 3)") - .option("--direction ", "IMPORT,EXPORT (default: both)") - .option("--status ", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)") - .option("--days-ahead ", "Days from now for departure (default: 3)") - .action(async function (this: any, args: any) { - const { app } = ctx; - const ds = app.get(DataSource); - - const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10))); - const directionList = (args.options?.direction ?? "IMPORT,EXPORT") - .split(",").map((s: string) => s.toUpperCase().trim()) - .filter((s: string) => s === "IMPORT" || s === "EXPORT"); - const statusList = (args.options?.status ?? "SCHEDULED") - .split(",").map((s: string) => s.toUpperCase().trim()) - .filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED"); - const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10)); - - if (directionList.length === 0 || statusList.length === 0) { - this.log("error: at least one direction and status required"); - return; - } - - const yards = await ds.getRepository(Yard).find({ where: { isActive: true } }); - const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y])); - const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti"); - const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia"); - - if (!djibouti || !addis) { - this.log("error: need at least one Djibouti and one Ethiopia yard"); - return; - } - - const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } }); - if (wagonTypes.length === 0) { - this.log("error: no wagon types found — seed reference data first"); - return; - } - - const wagonType = wagonTypes[0]; - const wagonCapacity = Number(wagonType.capacityTons) || 70; - const wagonLength = Number(wagonType.lengthMeters) || 14; - const tareWeight = Number(wagonType.tareWeightTons) || 14; - - const locomotiveRepo = ds.getRepository(Locomotive); - const scheduleRepo = ds.getRepository(TrainSchedule); - const trainSetRepo = ds.getRepository(TrainSet); - const wagonRepo = ds.getRepository(Wagon); - const routeRepo = ds.getRepository(Route); - const milestoneRepo = ds.getRepository(RouteMilestone); - - let nextTrainNum = await nextSequence(ds, "TST-SCH-*"); - const routePrefix = "TST-RTE-"; - let nextRouteNum = await nextRouteSeq(ds, routePrefix); - - const now = new Date(); - const travelHours = 11; - const intermediateYards = yards.filter( - (y) => y.id !== djibouti.id && y.id !== addis.id, - ); - - let loco = await locomotiveRepo.findOne({ where: { code: "TST-LOCO-01" } }); - if (!loco) { - loco = await locomotiveRepo.save( - locomotiveRepo.create({ - code: "TST-LOCO-01", - name: "Test Locomotive", - locomotiveType: "DIESEL", - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: "AVAILABLE", - currentYardId: djibouti.id, - }), - ); - } - - for (let i = 0; i < count; i++) { - const seq = nextTrainNum + i; - const trainNumber = `TST-SCH-${String(seq).padStart(5, "0")}`; - const dir = directionList[i % directionList.length]; - const status = statusList[i % statusList.length]; - const isDispatched = status === "DISPATCHED"; - const originYard = dir === "IMPORT" ? djibouti : addis; - const destYard = dir === "IMPORT" ? addis : djibouti; - const routeName = `${routePrefix}${String(nextRouteNum + i).padStart(3, "0")}`; - - const departure = new Date(now); - departure.setDate(departure.getDate() + daysAhead + i); - departure.setHours(7, 0, 0, 0); - const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000); - - const route = await routeRepo.save( - routeRepo.create({ - name: routeName, - originYardId: originYard.id, - destinationYardId: destYard.id, - isActive: true, - }), - ); - - await milestoneRepo.save( - milestoneRepo.create({ routeId: route.id, yardId: originYard.id, sequenceNo: 1 }), - ); - for (const [mi, y] of intermediateYards.entries()) { - await milestoneRepo.save( - milestoneRepo.create({ routeId: route.id, yardId: y.id, sequenceNo: (mi + 1) * 2 }), - ); - } - await milestoneRepo.save( - milestoneRepo.create({ - routeId: route.id, - yardId: destYard.id, - sequenceNo: (intermediateYards.length + 1) * 2, - }), - ); - - const totalWagonWeight = 4 * (tareWeight + 20); - const trainSet = await trainSetRepo.save( - trainSetRepo.create({ - locomotiveId: loco.id, - totalWeightTons: totalWagonWeight, - totalLengthMeters: wagonLength * 4, - wagonCount: 4, - status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED", - }), - ); - - await ds.getRepository(TrainSetLocomotive).save( - ds.getRepository(TrainSetLocomotive).create({ - trainSetId: trainSet.id, - locomotiveId: loco.id, - sequenceNo: 0, - }), - ); - - const schedule = await scheduleRepo.save( - scheduleRepo.create({ - trainSetId: trainSet.id, - routeId: route.id, - originStationId: originYard.id, - destinationStationId: destYard.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualDepartureAt: isDispatched ? departure : null, - status, - trainNumber, - direction: dir, - maxWagons: 53, - bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN", - }), - ); - - const wagonPrefix = `${trainNumber}-W`; - let nextWagon = await nextWagonSeq(ds, wagonPrefix); - for (let w = 0; w < 4; w++) { - const ws = nextWagon + w; - const wagonNumber = `${wagonPrefix}${String(ws).padStart(2, "0")}`; - - const wagon = wagonRepo.create({ - wagonNumber, - wagonTypeId: wagonType.id, - currentYardId: originYard.id, - currentTrainScheduleId: schedule.id, - tareWeight, - maxPayloadWeight: wagonCapacity, - status: isDispatched ? WagonStatus.Assigned : WagonStatus.Available, - notes: "Test seed wagon", - }); - const saved = await wagonRepo.save(wagon as any); - const physicalWagon = Array.isArray(saved) ? saved[0] : saved; - - await ds.getRepository(TrainSetWagon).save( - ds.getRepository(TrainSetWagon).create({ - trainSetId: trainSet.id, - wagonTypeId: wagonType.id, - physicalWagonId: physicalWagon.id, - sequenceNo: w + 1, - capacityTons: wagonCapacity, - lengthMeters: wagonLength, - assignedWeightTons: 20, - status: isDispatched ? "DEPARTED" : "PLANNED", - }), - ); - } - - this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label} → ${destYard.label})`); - } - - this.log(`Done — ${count} new train schedules created`); - }); -} diff --git a/apps/edr-freight-api/src/scripts/cmds/types.ts b/apps/edr-freight-api/src/scripts/cmds/types.ts deleted file mode 100644 index 6c805e07d..000000000 --- a/apps/edr-freight-api/src/scripts/cmds/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { INestApplicationContext } from "@nestjs/common"; - -export type CommandContext = { - app: INestApplicationContext; -}; diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts index 94b26347f..f3a304233 100644 --- a/apps/edr-freight-api/src/scripts/main.ts +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -4,7 +4,6 @@ import { config } from "dotenv"; config(); import Vorpal from "vorpal"; -import { registerCommands } from "./cmds/index"; import { NestFactory } from "@nestjs/core"; import { AppModule } from "../app.module"; @@ -17,7 +16,7 @@ async function main() { }); try { - registerCommands(vorpal, { app }); + // registerCommands(vorpal, { app }); const args = process.argv.slice(2); if (args.length > 0) { 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 * + + + + )} - + - +