diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8f03027a..d8413bf71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,8 +69,8 @@ jobs: fi echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") - echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal") - echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") diff --git a/.gitignore b/.gitignore index bb9b43556..ba46f7fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ coverage/ *.tsbuildinfo **/*.tsbuildinfo +**/vite.config.ts.timestamp-*.mjs # env .env diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 73e122a1d..73312aae3 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m TELEBIRR_PRIVATE_KEY= TELEBIRR_PUBLIC_KEY= TELEBIRR_INSECURE_TLS=false + +# Portal pages the payment provider redirects the browser to after payment. +# Point these at the freight portal's public payment result routes. +PAYMENT_RETURN_URL=http://localhost:5173/payment/success +PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure # JWT (used by @tria-plc/api-common SharedAuthModule) JWT_SECRET= JWT_ACCESS_TOKEN_SECRET= diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 3ddef331e..3fcde133e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -13,6 +13,7 @@ "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", + "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4d855e055..7857d7c7e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; -import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; -import { OtpModule } from './modules/otp/otp.module'; +import { OtpModule } from "./modules/otp/otp.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; @@ -44,9 +43,10 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; +import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -55,7 +55,10 @@ import { WagonsModule } from './modules/wagons/wagons.module'; import { ContainersModule } from './modules/container-management/containers.module'; import { CargoesModule } from './modules/cargoes/cargoes.module'; import { RoutesModule } from './modules/routes/routes.module'; +import { WarehousesModule } from './modules/warehouses/warehouses.module'; +import { FacilitiesModule } from './modules/facilities/facilities.module'; import { OverviewModule } from './modules/overview/overview.module'; +import { VehiclesModule } from './modules/vehicles/vehicles.module'; @Module({ imports: [ @@ -93,7 +96,6 @@ import { OverviewModule } from './modules/overview/overview.module'; TrainSchedulesModule, TrainSchedulingModule, SchedulingRescheduleModule, - CustomersModule, CompaniesModule, TrackingModule, BillingModule, @@ -112,17 +114,21 @@ import { OverviewModule } from './modules/overview/overview.module'; ContainersModule, CargoesModule, RoutesModule, + FacilitiesModule, + WarehousesModule, OverviewModule, + VehiclesModule, ], providers: [ EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, - DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, DemoFreightDataSeeder, + IndodeFacilitySeeder, + Batch14TestDataSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -131,9 +137,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, - private readonly demoBookingsSeeder: DemoBookingsSeeder, - private readonly pricingDataSeeder: PricingDataSeeder, - private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -144,11 +147,11 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); - await this.demoBookingsSeeder.run(); - await this.pricingDataSeeder.run(); - await this.fileUploadSettingsSeeder.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, + // FileUploadSettingsSeeder) are intentionally disabled — they stay + // registered as providers but are not run. Re-inject + call .run() to enable. + // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval + // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); } } diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 847908bd5..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -117,7 +117,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { ], migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). - synchronize: true, + synchronize: false, logging: process.env.NODE_ENV === "development", }; }); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 676e4f4ba..41a2f3b44 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -114,7 +114,9 @@ export class ContractViewModelBuilder { tinNumber: this.valueOrDash(booking.company?.tin), vatNumber: this.valueOrDash(booking.company?.vatNumber), fanNumber: this.valueOrDash(booking.company?.fanNumber), - businessLicense: this.valueOrDash(booking.company?.businessLicense), + businessLicense: this.valueOrDash( + booking.company?.companyProfiles?.[0]?.businessLicense, + ), }, provider: { name: 'Ethio-Djibouti Standard Gauge Railway Share Company', diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index a29fb861e..2ae202ebd 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -6,7 +6,7 @@ import { DataSource } from 'typeorm'; export const AppDataSource = new DataSource({ type: 'postgres', host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), + port: Number(process.env.DB_PORT ?? 5433), username: process.env.DB_USER ?? 'postgres', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_NAME ?? 'edr_freight', @@ -14,7 +14,7 @@ export const AppDataSource = new DataSource({ entities: [__dirname + '/**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, - logging: true, + logging: process.env.TYPEORM_LOGGING === 'true', }); // Optional: call ensurePostgresSchemas before initializing diff --git a/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts b/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts new file mode 100644 index 000000000..32e1ff653 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts @@ -0,0 +1,138 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateFacilitiesTable1750000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'facilities', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'code', + type: 'varchar', + length: '40', + isUnique: true, + }, + { + name: 'name', + type: 'varchar', + length: '160', + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'facility_type', + type: 'varchar', + length: '32', + }, + { + name: 'facility_status', + type: 'varchar', + length: '32', + default: "'ACTIVE'", + }, + { + name: 'location_name', + type: 'varchar', + length: '200', + isNullable: true, + }, + { + name: 'country', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'city', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'address', + type: 'text', + isNullable: true, + }, + { + name: 'latitude', + type: 'numeric', + precision: 10, + scale: 8, + isNullable: true, + }, + { + name: 'longitude', + type: 'numeric', + precision: 11, + scale: 8, + isNullable: true, + }, + { + name: 'capacity', + type: 'numeric', + precision: 14, + scale: 3, + isNullable: true, + }, + { + name: 'is_active', + type: 'boolean', + default: true, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'deleted_at', + type: 'timestamp', + isNullable: true, + }, + ], + }), + ); + + await queryRunner.createIndex( + 'freight.facilities', + new TableIndex({ + name: 'idx_facilities_code', + columnNames: ['code'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'freight.facilities', + new TableIndex({ + name: 'idx_facilities_status', + columnNames: ['facility_status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.facilities'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts b/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts new file mode 100644 index 000000000..82f2d92fd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm'; + +export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.warehouses'); + if (!table) { + // warehouses table doesn't exist yet, skip this migration + return; + } + + const hasColumn = table.columns.some((col) => col.name === 'facility_id'); + if (hasColumn) { + // Column already exists, skip + return; + } + + await queryRunner.addColumn( + 'freight.warehouses', + new TableColumn({ + name: 'facility_id', + type: 'uuid', + isNullable: true, + }), + ); + + await queryRunner.createForeignKey( + 'freight.warehouses', + new TableForeignKey({ + columnNames: ['facility_id'], + referencedColumnNames: ['id'], + referencedTableName: 'facilities', + referencedSchema: 'freight', + onDelete: 'SET NULL', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.warehouses'); + if (!table) { + return; + } + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id')); + if (foreignKey) { + await queryRunner.dropForeignKey('freight.warehouses', foreignKey); + } + const hasColumn = table.columns.some((col) => col.name === 'facility_id'); + if (hasColumn) { + await queryRunner.dropColumn('freight.warehouses', 'facility_id'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts b/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts new file mode 100644 index 000000000..67957f437 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Proof of Delivery (customer pickup) capture on cargoes: + * receiver name, delivered/picked-up timestamp, and delivery remarks. + */ +export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.cargoes'); + if (!table) { + // cargoes table doesn't exist yet, skip this migration + return; + } + + const columnsToAdd = [ + { name: 'receiver_name', type: 'varchar', isNullable: true }, + { name: 'delivered_at', type: 'timestamp', isNullable: true }, + { name: 'delivery_remarks', type: 'text', isNullable: true }, + ]; + + const columnsToCreate = columnsToAdd.filter( + (col) => !table.columns.some((c) => c.name === col.name), + ); + + if (columnsToCreate.length > 0) { + await queryRunner.addColumns( + 'freight.cargoes', + columnsToCreate.map((col) => new TableColumn(col)), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.cargoes'); + if (!table) { + return; + } + + const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks']; + const columnsToRemove = columnNames.filter((name) => + table.columns.some((c) => c.name === name), + ); + + if (columnsToRemove.length > 0) { + await queryRunner.dropColumns('freight.cargoes', columnsToRemove); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts new file mode 100644 index 000000000..88ef8d3a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +/** + * Batch 4.5 — warehouse inspection reports + inventory inspection status. + */ +export class AddWarehouseInspection1750000000003 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // inventory.inspection_status + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.warehouse_inventory', + new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }), + ); + } + } + + // warehouse_inspection_reports table + const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports'); + if (!inspectionTable) { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_inspection_reports', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" }, + { name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" }, + { name: 'has_damage', type: 'boolean', default: false }, + { name: 'damage_description', type: 'text', isNullable: true }, + { name: 'has_weight_loss', type: 'boolean', default: false }, + { name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true }, + { name: 'has_missing_items', type: 'boolean', default: false }, + { name: 'missing_items_description', type: 'text', isNullable: true }, + { name: 'remarks', type: 'text', isNullable: true }, + { name: 'inspected_by_id', type: 'uuid', isNullable: true }, + { name: 'inspected_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wir_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wir_booking', columnNames: ['booking_id'] }, + { name: 'idx_wir_status', columnNames: ['inspection_status'] }, + ], + }), + true, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports'); + if (inspectionTable) { + await queryRunner.dropTable('freight.warehouse_inspection_reports', true); + } + + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status'); + if (hasColumn) { + await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status'); + } + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts new file mode 100644 index 000000000..9ce1db6c1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface { + name = 'AddPhysicalWagonToTrainSetWagons1750200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'train_set_wagons' + AND constraint_name = 'fk_train_set_wagons_physical_wagon' + ) THEN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) + REFERENCES freight.wagons(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts new file mode 100644 index 000000000..454218fd7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface { + name = 'AddCurrentLocationToWagons1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'wagons' + AND constraint_name = 'FK_wagons_current_location_yard_id' + ) THEN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_current_location_yard_id" + FOREIGN KEY (current_location_yard_id) + REFERENCES freight.yards(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id" + ON freight.wagons(current_location_yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS current_location_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts new file mode 100644 index 000000000..0a4e96276 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts @@ -0,0 +1,214 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +type FleetRow = { + code: string; + name: string; + count: number; + start: number; + end: number; + capacityTons: number; + tareWeight: number; + lengthMeters: number; + supportedLoadTypes: string[]; +}; + +const FLEET: FleetRow[] = [ + { + code: 'PW2', + name: 'Box wagon', + count: 220, + start: 1, + end: 220, + capacityTons: 70, + tareWeight: 25.2, + lengthMeters: 17.066, + supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'], + }, + { + code: 'CW4', + name: 'Gondola wagon covered', + count: 110, + start: 221, + end: 330, + capacityTons: 70, + tareWeight: 24.8, + lengthMeters: 13.976, + supportedLoadTypes: ['CONTAINER'], + }, + { + code: 'CW3', + name: 'Gondola wagon', + count: 20, + start: 331, + end: 350, + capacityTons: 70, + tareWeight: 23.4, + lengthMeters: 13.976, + supportedLoadTypes: ['BULK', 'COAL', 'ORE'], + }, + { + code: 'KW2', + name: 'Hopper wagon covered', + count: 20, + start: 351, + end: 370, + capacityTons: 69, + tareWeight: 25.2, + lengthMeters: 16.466, + supportedLoadTypes: ['BULK', 'GRAIN'], + }, + { + code: 'KW3', + name: 'Hopper wagon', + count: 20, + start: 371, + end: 390, + capacityTons: 70, + tareWeight: 24, + lengthMeters: 14.4, + supportedLoadTypes: ['BULK', 'COAL'], + }, + { + code: 'NW5', + name: 'Flat wagon container', + count: 550, + start: 391, + end: 940, + capacityTons: 70, + tareWeight: 0, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdRWagonFleet1750400000000 implements MigrationInterface { + name = 'SeedEdRWagonFleet1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagon_types + SET name = 'Flat wagon container', + capacity_tons = 70, + length_meters = 14.000, + supported_load_types = ARRAY['CONTAINER'], + max_wagons_per_train = 53, + is_active = true, + deleted_at = NULL, + updated_at = now() + WHERE code = 'NW5'; + `); + + const [defaultLocation] = await queryRunner.query(` + SELECT id + FROM freight.yards + WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD') + OR lower(label) LIKE '%djibouti%' + ORDER BY + CASE code + WHEN 'DJIBOUTI' THEN 1 + WHEN 'DJIB_PORT' THEN 2 + WHEN 'NAGAD' THEN 3 + ELSE 4 + END, + display_order ASC + LIMIT 1; + `); + const defaultLocationYardId = defaultLocation?.id ?? null; + + for (const row of FLEET) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active + ) + VALUES ($1, $2, $3, $4, $5, $6::text[], true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [ + row.code, + row.name, + row.capacityTons, + row.lengthMeters, + row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37, + row.supportedLoadTypes, + ], + ); + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_seed_failed:${row.code}`); + } + + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + for (let sequence = row.start; sequence <= row.end; sequence += 1) { + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + current_location_yard_id, + status, + notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (wagon_number) DO UPDATE SET + wagon_type_id = EXCLUDED.wagon_type_id, + tare_weight = EXCLUDED.tare_weight, + max_payload_weight = EXCLUDED.max_payload_weight, + current_location_yard_id = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id + ELSE freight.wagons.current_location_yard_id + END, + status = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status + ELSE freight.wagons.status + END, + notes = EXCLUDED.notes, + updated_at = now(); + `, + [ + wagonNumber(sequence), + typeRecord.id, + row.tareWeight, + row.capacityTons, + defaultLocationYardId, + defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE', + `Seeded Ethio-Djibouti Railway ${row.code} fleet record.`, + ], + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.wagons + WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts new file mode 100644 index 000000000..a27cef3ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts @@ -0,0 +1,103 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; + +export class CreateCompanyProfiles1752000000000 implements MigrationInterface { + name = "CreateCompanyProfiles1752000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: "freight", + name: "company_profiles", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "gen_random_uuid()", + }, + { name: "company_id", type: "uuid" }, + { name: "type", type: "varchar", length: "32" }, + { name: "reference", type: "varchar", length: "20", isUnique: true }, + { + name: "status", + type: "varchar", + length: "32", + default: "'active'", + }, + { + name: "business_license", + type: "varchar", + length: "100", + isNullable: true, + }, + { name: "attributes", type: "jsonb", isNullable: true }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + "freight.company_profiles", + new TableForeignKey({ + columnNames: ["company_id"], + referencedTableName: "companies", + referencedSchema: "freight", + referencedColumnNames: ["id"], + }), + ); + + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["company_id"] }), + ); + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["type"] }), + ); + + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("freight.company_profiles"); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts new file mode 100644 index 000000000..f15996773 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveBusinessLicenseToProfile1752000000001 + implements MigrationInterface +{ + name = 'MoveBusinessLicenseToProfile1752000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.company_profiles cp + SET business_license = c.business_license + FROM freight.companies c + WHERE cp.company_id = c.id AND c.business_license IS NOT NULL + `); + + await queryRunner.query( + `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`, + ); + + await queryRunner.query(` + UPDATE freight.companies c + SET business_license = cp.business_license + FROM ( + SELECT DISTINCT ON (cp2.company_id) + cp2.company_id, cp2.business_license + FROM freight.company_profiles cp2 + WHERE cp2.business_license IS NOT NULL + ORDER BY cp2.company_id, cp2.created_at + ) cp + WHERE cp.company_id = c.id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts new file mode 100644 index 000000000..2cf09c9f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVehiclesTable1770000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN + CREATE TABLE freight.vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plate_number VARCHAR NOT NULL UNIQUE, + registration_number VARCHAR NOT NULL UNIQUE, + vehicle_type VARCHAR NOT NULL, + manufacturer VARCHAR NOT NULL, + model VARCHAR NOT NULL, + year INTEGER NOT NULL, + fuel_type VARCHAR NOT NULL, + capacity NUMERIC NOT NULL, + status VARCHAR DEFAULT 'ACTIVE' NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL + ); + + CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number); + CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number); + CREATE INDEX idx_vehicles_status ON freight.vehicles(status); + CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type); + CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts new file mode 100644 index 000000000..a615fe9a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Full wagon re-seed — runs in this order: + * + * 1. DELETE all existing wagons (hard delete, not soft). + * 2. UPSERT all 10 standard wagon types so they are guaranteed to exist. + * 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across + * the 5 main operational yards (10 wagons per yard per type): + * + * KALITY — Kality Rail Terminal + * MOJO — Mojo Dry Port + * DIRE_DAWA — Dire Dawa Yard + * DJIB_PORT — Djibouti Port Terminal + * NAGAD — Nagad Terminal, Djibouti + * + * Wagon numbers follow the pattern -NNNN (e.g. NW5-0001 … NW5-0050). + * Yard IDs are fetched live from freight.yards so the migration is safe across + * all environments regardless of UUID values. + */ +export class SeedWagonsWithYardAssignment1784000000001 + implements MigrationInterface +{ + name = 'SeedWagonsWithYardAssignment1784000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── STEP 1: Remove all wagons ────────────────────────────────────────── + await queryRunner.query(`DELETE FROM freight.wagons;`); + + // ── STEP 2: Ensure all 10 wagon types exist ──────────────────────────── + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active, + tare_weight_tons + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + tare_weight_tons = EXCLUDED.tare_weight_tons, + deleted_at = NULL, + updated_at = now(); + `); + + // ── STEP 3: Seed 50 wagons per type across 5 yards ──────────────────── + await queryRunner.query(` + DO $$ + DECLARE + wt RECORD; + yard_kality UUID; + yard_mojo UUID; + yard_dire_dawa UUID; + yard_djib_port UUID; + yard_nagad UUID; + yards UUID[]; + i INT; + yard_id UUID; + wagon_num TEXT; + v_tare NUMERIC; + v_payload NUMERIC; + BEGIN + -- Fetch yard IDs by code (safe across envs — UUIDs differ per DB) + SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1; + SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1; + SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1; + SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1; + SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1; + + IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL + OR yard_djib_port IS NULL OR yard_nagad IS NULL + THEN + RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.'; + END IF; + + yards := ARRAY[ + yard_kality, + yard_mojo, + yard_dire_dawa, + yard_djib_port, + yard_nagad + ]; + + FOR wt IN + SELECT id, code, capacity_tons, tare_weight_tons + FROM freight.wagon_types + WHERE is_active = true + ORDER BY code + LOOP + v_tare := COALESCE(wt.tare_weight_tons, 20.0); + v_payload := COALESCE(wt.capacity_tons, 60.0); + + FOR i IN 1 .. 50 LOOP + wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0'); + yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K … + + INSERT INTO freight.wagons ( + id, + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id, + created_at, + updated_at + ) + VALUES ( + uuid_generate_v4(), + wagon_num, + wt.id, + v_tare, + v_payload, + 'Available', + yard_id, + NULL, NULL, NULL, NULL, NULL, + now(), now() + ) + ON CONFLICT (wagon_number) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code; + END LOOP; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove all seeded wagons (full wipe — mirrors what up() did) + await queryRunner.query(`DELETE FROM freight.wagons;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts new file mode 100644 index 000000000..544600bef --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Day-level booking pool: customers select a DAY (route + day), not a specific + * train. The batch engine's pool query filters bookings on + * (origin_yard_id, destination_yard_id, scheduled_date, status); this partial + * index backs that scan. + */ +export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_route_day + ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts new file mode 100644 index 000000000..05b1259f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts @@ -0,0 +1,125 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +/** + * Batch 5 — warehouse allocation rules, storage/demurrage fee rules, + * and demurrage lifecycle timestamps on inventory. + */ +export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_allocation_rules', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'name', type: 'varchar', length: '160' }, + { name: 'priority', type: 'int', default: 100 }, + { name: 'freight_type', type: 'varchar', length: '16', isNullable: true }, + { name: 'trade_direction', type: 'varchar', length: '16', isNullable: true }, + { name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true }, + { name: 'container_status', type: 'varchar', length: '24', isNullable: true }, + { name: 'requires_inspection', type: 'boolean', isNullable: true }, + { name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'target_yard_code', type: 'varchar', length: '40' }, + { name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'storage_type', type: 'varchar', length: '80', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_war_priority', columnNames: ['priority'] }, + { name: 'idx_war_active', columnNames: ['is_active'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_rules', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'name', type: 'varchar', length: '160' }, + { name: 'rule_type', type: 'varchar', length: '20' }, + { name: 'priority', type: 'int', default: 100 }, + { name: 'freight_type', type: 'varchar', length: '16', isNullable: true }, + { name: 'trade_direction', type: 'varchar', length: '16', isNullable: true }, + { name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true }, + { name: 'container_type', type: 'varchar', length: '40', isNullable: true }, + { name: 'facility_id', type: 'uuid', isNullable: true }, + { name: 'warehouse_id', type: 'uuid', isNullable: true }, + { name: 'yard_id', type: 'uuid', isNullable: true }, + { 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: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wfr_type', columnNames: ['rule_type'] }, + { name: 'idx_wfr_active', columnNames: ['is_active'] }, + ], + }), + true, + ); + + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const columnsToAdd = [ + { name: 'inspection_started_at', type: 'timestamptz', isNullable: true }, + { name: 'inspection_completed_at', type: 'timestamptz', isNullable: true }, + { name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true }, + { name: 'release_date', type: 'timestamptz', isNullable: true }, + { name: 'gate_cleared_at', type: 'timestamptz', isNullable: true }, + ]; + + const columnsToCreate = columnsToAdd.filter( + (col) => !inventoryTable.columns.some((c) => c.name === col.name), + ); + + if (columnsToCreate.length > 0) { + await queryRunner.addColumns( + 'freight.warehouse_inventory', + columnsToCreate.map((col) => new TableColumn(col)), + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const columnNames = [ + 'inspection_started_at', + 'inspection_completed_at', + 'ready_for_pickup_at', + 'release_date', + 'gate_cleared_at', + ]; + const columnsToRemove = columnNames.filter((name) => + inventoryTable.columns.some((c) => c.name === name), + ); + + if (columnsToRemove.length > 0) { + await queryRunner.dropColumns('freight.warehouse_inventory', columnsToRemove); + } + } + + const feeRulesTable = await queryRunner.getTable('freight.warehouse_fee_rules'); + if (feeRulesTable) { + await queryRunner.dropTable('freight.warehouse_fee_rules', true); + } + + const allocationRulesTable = await queryRunner.getTable('freight.warehouse_allocation_rules'); + if (allocationRulesTable) { + await queryRunner.dropTable('freight.warehouse_allocation_rules', true); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts new file mode 100644 index 000000000..b921a7194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWarehouseModule1790000000000 implements MigrationInterface { + name = 'CreateWarehouseModule1790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL UNIQUE, + type VARCHAR(32) NOT NULL, + station_id UUID NULL, + location_name VARCHAR(200) NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_yards_code UNIQUE (warehouse_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_zones_code UNIQUE (yard_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id), + zone_id UUID NOT NULL REFERENCES freight.warehouse_zones(id), + booking_id UUID NOT NULL, + cargo_id UUID NULL, + container_id UUID NULL, + goods_id UUID NULL, + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight NUMERIC(14,3) NOT NULL DEFAULT 0, + volume NUMERIC(12,3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + arrived_at TIMESTAMPTZ NULL, + inspected_at TIMESTAMPTZ NULL, + ready_for_loading_at TIMESTAMPTZ NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + const indexes: Array<[string, string, string]> = [ + ['idx_warehouses_type', 'warehouses', 'type'], + ['idx_warehouses_status', 'warehouses', 'status'], + ['idx_warehouses_station_id', 'warehouses', 'station_id'], + ['idx_warehouse_yards_warehouse_id', 'warehouse_yards', 'warehouse_id'], + ['idx_warehouse_yards_type', 'warehouse_yards', 'type'], + ['idx_warehouse_yards_status', 'warehouse_yards', 'status'], + ['idx_warehouse_zones_yard_id', 'warehouse_zones', 'yard_id'], + ['idx_warehouse_zones_type', 'warehouse_zones', 'type'], + ['idx_warehouse_zones_status', 'warehouse_zones', 'status'], + ['idx_warehouse_inventory_warehouse_id', 'warehouse_inventory', 'warehouse_id'], + ['idx_warehouse_inventory_yard_id', 'warehouse_inventory', 'yard_id'], + ['idx_warehouse_inventory_zone_id', 'warehouse_inventory', 'zone_id'], + ['idx_warehouse_inventory_booking_id', 'warehouse_inventory', 'booking_id'], + ['idx_warehouse_inventory_cargo_id', 'warehouse_inventory', 'cargo_id'], + ['idx_warehouse_inventory_container_id', 'warehouse_inventory', 'container_id'], + ['idx_warehouse_inventory_goods_id', 'warehouse_inventory', 'goods_id'], + ['idx_warehouse_inventory_status', 'warehouse_inventory', 'status'], + ]; + + for (const [indexName, table, column] of indexes) { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS ${indexName} ON freight.${table}(${column});`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_yards;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouses;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts new file mode 100644 index 000000000..c34eb240a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +/** Batch 6 — warehouse fee invoices + invoice items. */ +export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoices', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_number', type: 'varchar', length: '40', isUnique: true }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'facility_id', type: 'uuid', isNullable: true }, + { name: 'warehouse_id', type: 'uuid', isNullable: true }, + { name: 'yard_id', type: 'uuid', isNullable: true }, + { name: 'zone_id', type: 'uuid', isNullable: true }, + { name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" }, + { name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" }, + { name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'period_start', type: 'timestamptz', isNullable: true }, + { name: 'period_end', type: 'timestamptz', isNullable: true }, + { name: 'issued_at', type: 'timestamptz', isNullable: true }, + { name: 'due_date', type: 'timestamptz', isNullable: true }, + { name: 'paid_at', type: 'timestamptz', isNullable: true }, + { name: 'cancelled_at', type: 'timestamptz', isNullable: true }, + { name: 'payments', type: 'jsonb', default: "'[]'" }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wfi_booking', columnNames: ['booking_id'] }, + { name: 'idx_wfi_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wfi_status', columnNames: ['status'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoice_items', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_id', type: 'uuid' }, + { name: 'fee_rule_id', type: 'uuid', isNullable: true }, + { name: 'fee_type', type: 'varchar', length: '32' }, + { name: 'description', type: 'varchar', length: '255' }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }, + { name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'chargeable_days', type: 'int', isNullable: true }, + { name: 'free_days', type: 'int', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['invoice_id'], + referencedSchema: 'freight', + referencedTableName: 'warehouse_fee_invoices', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true); + await queryRunner.dropTable('freight.warehouse_fee_invoices', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts new file mode 100644 index 000000000..a1431d7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts @@ -0,0 +1,123 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WarehouseBatch21790000000001 implements MigrationInterface { + name = 'WarehouseBatch21790000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── Capacity columns (weight + volume) on warehouse / yard / zone ────── + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0; + `); + // Backfill max_weight from the Batch 1 capacity_weight column. + await queryRunner.query(` + UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL; + `); + } + + // ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ─────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION'; + `); + + // ── New lifecycle timestamps ────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL; + `); + + // booking_id becomes nullable (inventory can exist before booking linkage). + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL; + `); + + // ── Movement history ────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE, + from_warehouse_id UUID NOT NULL, + from_yard_id UUID NOT NULL, + from_zone_id UUID NOT NULL, + to_warehouse_id UUID NOT NULL, + to_yard_id UUID NOT NULL, + to_zone_id UUID NOT NULL, + remarks TEXT NULL, + moved_by VARCHAR(120) NULL, + moved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id + ON freight.warehouse_inventory_movement(inventory_id); + `); + + // ── Activity log ────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NULL, + warehouse_id UUID NULL, + activity_type VARCHAR(40) NOT NULL, + description TEXT NULL, + performed_by VARCHAR(120) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id + ON freight.warehouse_activity_log(inventory_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id + ON freight.warehouse_activity_log(warehouse_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type + ON freight.warehouse_activity_log(activity_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`); + + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS stored_at, + DROP COLUMN IF EXISTS reserved_at, + DROP COLUMN IF EXISTS loaded_at, + DROP COLUMN IF EXISTS dispatched_at; + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + DROP COLUMN IF EXISTS max_weight, + DROP COLUMN IF EXISTS max_volume, + DROP COLUMN IF EXISTS current_volume; + `); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts new file mode 100644 index 000000000..4dcc9329c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Batch 3 — Warehouse → Loading → Train Departure visibility. + * Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any + * scheduling / wagon tables — the warehouse only reads from those. + */ +export class WarehouseBatch31790000000002 implements MigrationInterface { + name = 'WarehouseBatch31790000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_loadings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE, + booking_id UUID NULL, + wagon_id UUID NOT NULL, + loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + loaded_by VARCHAR(120) NULL, + loaded_weight NUMERIC(14,3) NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_inventory_id + ON freight.warehouse_loadings(warehouse_inventory_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id + ON freight.warehouse_loadings(booking_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id + ON freight.warehouse_loadings(wagon_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`); + } +} 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 b2c3ffbfb..c41013ddb 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 @@ -1,6 +1,5 @@ import { BadRequestException, - ConflictException, forwardRef, Inject, Injectable, @@ -69,7 +68,12 @@ export class BookingTransitionService { priorityScore, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Auto-consolidate now: a partial-wagon booking either pairs with a waiting + // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one + // arrives. The returned status reflects that outcome. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -143,7 +147,10 @@ export class BookingTransitionService { }, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Same consolidation treatment as the direct submit path. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -188,18 +195,11 @@ export class BookingTransitionService { async acceptIntake(bookingId: string, actorId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + // Only SUBMITTED bookings are acceptable. A booking that still needs + // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and + // is therefore never offered for accept until a partner moves it to SUBMITTED. assertBookingStatus(booking, ['SUBMITTED']); - // Consolidation gate: a booking whose containers don't fill whole wagons - // cannot be accepted until it is paired with a complementary booking. - const gate = await this.bookingsService.resolveConsolidationGate(bookingId); - if (gate.blocked) { - throw new ConflictException( - gate.message ?? - 'Booking requires consolidation and cannot be accepted until a partner is found.', - ); - } - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId, 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 ba9fffb66..ae0f1765a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -11,6 +11,7 @@ import { Query, Request, Res, + UnauthorizedException, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -117,8 +118,22 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List freight bookings (paginated)' }) - findAll(@Query() filter: FilterBookingDto) { - return this.bookingsService.findAll(filter); + async findAll( + @Query() filter: FilterBookingDto, + @CurrentUser() user: TCurrentUser, + ) { + // Staff (backoffice) see every booking. Customers (portal) are always + // force-scoped to their own company, regardless of any companyId they pass. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + return this.bookingsService.findAll(filter); + } + const userId = user?.id; + if (!userId) throw new UnauthorizedException('Authentication required'); + const companyId = + await this.bookingsService.resolveCustomerCompanyId(userId); + // No linked company yet → no bookings to show (avoids leaking all bookings). + if (!companyId) return { items: [], total: 0 }; + return this.bookingsService.findAll(filter, companyId); } @Get('list-summary') @@ -166,18 +181,60 @@ export class BookingsController { @Get('by-reference/:reference') @ApiOperation({ summary: 'Get booking by reference' }) - async findByReference(@Param('reference') reference: string) { + async findByReference( + @Param('reference') reference: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findByReference(reference); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) - async findOne(@Param('id', ParseUUIDPipe) id: string) { + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/tracking') + @ApiOperation({ + summary: 'Shipment tracking timeline for a booking', + description: + "Returns the booking's consignment (once dispatched) and its ordered " + + 'tracking events. Scoped to the customer\'s own company.', + }) + async findTracking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.getBookingTracking(id); + } + @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) 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 d304e2946..b173bfe68 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository { .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') + // Only pair bookings the customer has committed (SUBMITTED) or that are + // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so + // pairing never prematurely submits an unfinished/unpriced draft. .andWhere('b.status IN (:...statuses)', { - statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], }) .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, @@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository { destinationStationId?: string; schedulingStatus?: string; trainScheduleId?: string; + /** + * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees + * the whole (route, day) pool rather than bookings pre-targeted to one train. + */ + day?: string; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository { .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .andWhere('scheduleBooking.id IS NULL'); - // Mirror the automatic batch pool: a schedule only ever considers bookings that - // targeted THAT schedule (same as findBatchPool's train_schedule_id filter). - if (options.trainScheduleId) { + // Day-level pooling: customers no longer set train_schedule_id, so the wizard + // surfaces the whole (route, EAT day) pool. Fall back to the legacy + // single-schedule filter only when no day is supplied (e.g. a staff-pinned + // booking that still carries train_schedule_id). + if (options.day) { + qb.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day: options.day }, + ); + } else if (options.trainScheduleId) { qb.andWhere('booking.train_schedule_id = :trainScheduleId', { trainScheduleId: options.trainScheduleId, }); @@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Day-level batch pool: ready, not-yet-allocated bookings on a route for one + * EAT calendar day, regardless of which train they end up on. Same status + * rules and ordering as {@link findBatchPool}, but keyed on + * (origin, destination, day) instead of train_schedule_id — the engine then + * distributes these across all trains departing that day. + */ + findBatchPoolByRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id = :originYardId', { originYardId }) + .andWhere('booking.destination_yard_id = :destinationYardId', { + destinationYardId, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ebf8273de..4c8ef2cab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,12 +1,17 @@ import { BadRequestException, ConflictException, + ForbiddenException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; -import { SchedulingStatus } from '@edr/types'; +import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -52,6 +57,8 @@ export class BookingsService { private readonly minioService: MinioService, // private readonly customersService: CustomersService, private readonly companiesService: CompaniesService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, @@ -146,13 +153,17 @@ export class BookingsService { /** * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). + * + * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a + * half-empty wagon, so `explicit === false` is ignored when consolidation is + * actually needed. The opt-in flag only matters for cargo that already fills + * whole wagons (where consolidation is moot anyway). */ private async resolveConsolidation( containers: CreateBookingContainerDto[], explicit?: boolean, ): Promise { - if (explicit === false) return false; const needs = await this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, @@ -193,10 +204,11 @@ export class BookingsService { return { booking: paired, messages }; } - if (booking.status === 'DRAFT') { - await this.bookingsRepository.update(booking.id, { - status: 'PENDING_CONSOLIDATION', - } as never); + // No partner yet — park the booking so it waits. Applies both pre-submit + // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never + // reach this method. + if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { + await this.bookingsRepository.parkForConsolidation(booking.id); } const pending = await this.findById(booking.id); @@ -205,45 +217,24 @@ export class BookingsService { } /** - * Consolidation gate used at staff-accept time. Returns the (possibly newly - * paired) booking plus whether it still needs a consolidation partner. - * When a booking needs consolidation and none is found, it is parked in - * PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept. + * Run consolidation right after a booking reaches SUBMITTED. If a complementary + * partner already exists, both are paired and moved (back) to SUBMITTED so staff + * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and + * waits for a later complementary booking to complete the wagon. + * + * Returns the re-fetched booking, so callers can reflect the resulting status + * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). */ - async resolveConsolidationGate(bookingId: string): Promise<{ - booking: Booking; - blocked: boolean; - message?: string; - }> { - let booking = await this.findById(bookingId); + async runConsolidationOnSubmit(bookingId: string): Promise { + const booking = await this.findById(bookingId); - // Already paired — passes the gate. + // Already paired (e.g. a partner submitted first) — nothing to do. if (booking.consolidationPartnerId) { - return { booking, blocked: false }; + return booking; } - const needs = - await this.consolidationService.needsConsolidationFromBooking(booking); - if (!needs) { - return { booking, blocked: false }; - } - - // A partner may have appeared since submission — try to pair now. const result = await this.tryAutoConsolidate(booking); - booking = result.booking; - if (booking.consolidationPartnerId) { - return { booking, blocked: false, message: result.messages.join(' ') }; - } - - // Still no partner — park it and block the accept. - await this.bookingsRepository.parkForConsolidation(booking.id); - booking = await this.findById(booking.id); - const slots = await this.consolidationService.slotsFromBooking(booking); - return { - booking, - blocked: true, - message: this.consolidationService.describePending(booking, slots), - }; + return result.booking; } /** Create a new freight booking. */ @@ -283,8 +274,8 @@ export class BookingsService { companyId = company.id; } - // Schedule targeting: when provided, the schedule must be OPEN and on the same route. if (dto.trainScheduleId) { + // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: dto.trainScheduleId } }); @@ -300,6 +291,22 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } + } else { + // Day-level pool: the customer picked a DAY — require that the route has at + // least one OPEN departure on that EAT day. The batch engine assigns the + // train later. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + dto.originYardId, + dto.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } } const reference = dto.reference || (await this.generateReference()); @@ -575,6 +582,7 @@ export class BookingsService { /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, + forceCompanyId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -587,7 +595,9 @@ export class BookingsService { ...statusFilter, ...schedulingStatusFilter, assignedToSchedule: filter.assignedToSchedule, - companyId: filter.companyId, + // A forced company scope (portal/customer) overrides any caller-provided + // companyId so a customer can only ever see their own company's bookings. + companyId: forceCompanyId ?? filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -631,6 +641,109 @@ export class BookingsService { }); } + /** + * Resolve the company a customer user belongs to, for scoping their own + * bookings. Returns null when no profile/company is linked yet. + */ + async resolveCustomerCompanyId(userId: string): Promise { + try { + const { company } = + await this.companiesService.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** + * Authorize a customer's access to a single booking. Staff are scoped at the + * controller (they pass `isStaff`); for a customer, the booking must belong + * to the company the authenticated user is linked to — otherwise it is hidden + * behind a NotFound so booking IDs can't be probed. + */ + async assertCustomerCanAccessBooking( + userId: string | undefined, + booking: Booking, + ): Promise { + if (!userId) { + throw new ForbiddenException('Authentication required'); + } + const companyId = await this.resolveCustomerCompanyId(userId); + if (!companyId || booking.companyId !== companyId) { + // Don't reveal that the booking exists for another company. + throw new NotFoundException(`Booking ${booking.id} not found`); + } + } + + /** + * Build the customer-facing shipment tracking payload for a booking from the + * train schedule it is assigned to and the live checkpoint log. The caller is + * responsible for authorizing access to the booking first. + * + * When the booking has not been assigned to a train yet, returns a valid + * "no schedule" payload so the UI can show a pre-dispatch state. + */ + async getBookingTracking( + bookingId: string, + ): Promise { + const booking = await this.findById(bookingId); + + const empty: Freight.IBookingTracking = { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: false, + scheduleId: null, + trainNumber: null, + scheduleStatus: null, + direction: null, + origin: null, + destination: null, + stations: [], + checkpoints: [], + currentSequenceNo: -1, + actualDepartureAt: null, + actualArrivalAt: null, + scheduledDepartureAt: null, + scheduledArrivalAt: null, + }; + + if (!booking.trainScheduleId) { + return empty; + } + + // Pull the live corridor + checkpoints for the assigned schedule. If the + // schedule was removed, fall back to the pre-dispatch state rather than 500. + let track: Awaited< + ReturnType + >; + try { + track = await this.trainSchedulingService.getScheduleCheckpoints( + booking.trainScheduleId, + ); + } catch { + return empty; + } + + return { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: true, + scheduleId: track.scheduleId, + trainNumber: track.trainNumber, + scheduleStatus: track.status as Freight.TrainScheduleStatus, + direction: track.direction, + origin: track.origin, + destination: track.destination, + stations: track.stations, + checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], + currentSequenceNo: track.currentSequenceNo, + actualDepartureAt: track.actualDepartureAt, + actualArrivalAt: track.actualArrivalAt, + scheduledDepartureAt: track.scheduledDepartureAt, + scheduledArrivalAt: track.scheduledArrivalAt, + }; + } + /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; 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 3c7eca391..fa3bb6f4d 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 @@ -91,12 +91,21 @@ export class CreateBookingDto { @IsUUID() trainId?: string; - /** Target schedule this booking is created against (required by the backoffice create form). */ - @ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' }) + /** + * Staff-only manual pin to a specific train. Customers omit this — they pick a + * DAY via {@link scheduledDate} and the batch engine assigns a train within + * that (route, day) pool. When provided, the schedule must be OPEN and on the + * booking route. + */ + @ApiPropertyOptional({ + format: 'uuid', + description: 'Staff only: pin to a specific train schedule. Customers omit this.', + }) @IsOptional() @IsUUID() trainScheduleId?: string; + /** The day the customer wants to ship (the pool day key). */ @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: 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 d6f7f9b55..c5dac1736 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 @@ -23,6 +23,9 @@ export const BOOKING_STATUSES = [ 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', 'APPROVED', + 'READY_FOR_ASSIGNMENT', + 'WAGON_ASSIGNED', + 'INVOICED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', @@ -277,7 +280,15 @@ export class Booking extends BaseEntity { @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) scheduledAt?: Date | null; - /** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */ + /** + * The train this booking is assigned to. FK to train_schedules. + * + * Day-level pooling: customers no longer pick a train — they pick a DAY, and + * this stays null at creation. The batch engine sets it when it assigns the + * booking to a specific train within its (route, day) pool; staff may also + * pin it manually. The day-level pool is keyed on + * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column. + */ @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts index 6c79f0a76..6f73035b4 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -157,7 +157,9 @@ export class CargoesService { } cargo.status = 'DELIVERED'; - if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; + cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date(); + if (dto?.receiverName) cargo.receiverName = dto.receiverName; + if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks; const remaining = cargo.containerId != null diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts index 020e4d630..de402a33e 100644 --- a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts +++ b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts @@ -1,6 +1,16 @@ -import { IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; export class DeliverCargoDto { + /** Name of the person who received / picked up the cargo (Proof of Delivery). */ + @IsOptional() + @IsString() + receiverName?: string; + + /** When the cargo was picked up / delivered. Defaults to now. */ + @IsOptional() + @IsDateString() + pickupDate?: string; + @IsOptional() @IsString() deliveryRemarks?: string; diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts index ffc4bb26a..685f9b659 100644 --- a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -40,6 +40,16 @@ export class Cargo extends BaseEntity { @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) unloadedAt!: Date | null; + // Proof of Delivery (customer pickup) capture. + @Column({ name: 'receiver_name', type: 'varchar', nullable: true }) + receiverName!: string | null; + + @Column({ name: 'delivered_at', type: 'timestamp', nullable: true }) + deliveredAt!: Date | null; + + @Column({ name: 'delivery_remarks', type: 'text', nullable: true }) + deliveryRemarks!: string | null; + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) wagonBookingAllocationId!: string | null; @@ -57,6 +67,7 @@ export class Cargo extends BaseEntity { @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) loadType!: string | null; + // Relationship to Container @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'container_id' }) container!: Container | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 81fba19fb..ac2868ec3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -1,22 +1,38 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; -import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; -import { CurrentUser } from '@edr/api-common'; -import { FreightAdmin } from '../../common/booking-guards'; -import { FilesService } from '../files/files.service'; -import { CompaniesService } from './companies.service'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { ResponseCompanyDto } from './dto/response-company.dto'; -import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; -import { ResponseFFClientDto } from './dto/response-ff-client.dto'; -import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFiles, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import { FreightAdmin } from "../../common/booking-guards"; +import { FilesService } from "../files/files.service"; +import { CompaniesService } from "./companies.service"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { + ResponseCompanyDto, + ResponseCompanyProfileDto, +} from "./dto/response-company.dto"; +import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; +import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; interface CurrentIamUser { id: string; @@ -25,36 +41,47 @@ interface CurrentIamUser { phoneNumber?: string; } -@ApiTags('Companies') -@Controller('companies') +@ApiTags("Companies") +@Controller("companies") export class CompaniesController { constructor( private readonly companiesService: CompaniesService, private readonly filesService: FilesService, - ) {} + ) { } - @Get('getInfo') - @ApiOperation({ summary: 'Get company info for the current user' }) - async getInfo(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("getInfo") + @ApiOperation({ summary: "Get company info for the current user" }) + async getInfo( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new CompanyInfoResponseDto(profile, company); } - @Get('profile') - @ApiOperation({ summary: 'Get flattened profile for the settings page' }) - async getProfile(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("profile") + @ApiOperation({ summary: "Get flattened profile for the settings page" }) + async getProfile( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new ProfileResponseDto(profile, company); } - @Get('dashboard') - @ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' }) - async getDashboard(@CurrentUser() user: CurrentIamUser): Promise { + @Get("dashboard") + @ApiOperation({ + summary: + "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", + }) + async getDashboard( + @CurrentUser() user: CurrentIamUser, + ): Promise { return this.companiesService.getDashboardSummary(user.id); } - @Patch('profile') - @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + @Patch("profile") + @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: UpdateProfileDto, @@ -62,145 +89,153 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } - @Post('create') - @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) + @Post("company-profiles") + @ApiOperation({ + summary: + "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", + }) + async addCompanyProfiles( + @CurrentUser() user: CurrentIamUser, + @Body() dto: AddCompanyProfilesDto, + ): Promise { + const profiles = await this.companiesService.addCompanyProfilesForUser( + user.id, + dto.types, + ); + return profiles.map((p) => new ResponseCompanyProfileDto(p)); + } + + // Used by portal + @Post("create") + @ApiOperation({ + summary: + "Create a company with its associated external profile (onboarding)", + }) async createWithProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: CreateCompanyWithProfileDto, ): Promise { - const nameParts = (user.name?.en ?? '').split(' '); - const { profile, company } = await this.companiesService.createCompanyWithProfile( - { - userId: user.id, - firstName: nameParts[0] || '', - lastName: nameParts.slice(-1)[0] || '', - email: user.email ?? '', - phone: user.phoneNumber ?? '', - }, - dto, - ); + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = + await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto, + ); return new CompanyInfoResponseDto(profile, company); } + // Used by backoffice @Post() @FreightAdmin() - @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) + @ApiOperation({ + summary: + "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", + }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); return new ResponseCompanyDto(company); } @Get() - @ApiOperation({ summary: 'List all companies' }) + @ApiOperation({ summary: "List all companies" }) async findAll(): Promise { const companies = await this.companiesService.findAllCompanies(); return companies.map((c) => new ResponseCompanyDto(c)); } - @Get('type/:type') - @ApiOperation({ summary: 'Find companies by type' }) - async findByType(@Param('type') type: string): Promise { + @Get("type/:type") + @ApiOperation({ summary: "Find companies by type" }) + async findByType(@Param("type") type: string): Promise { const companies = await this.companiesService.findAllCompanies(); - return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c)); + return companies + .filter((c) => c.type === type) + .map((c) => new ResponseCompanyDto(c)); } - @Get('search') - @ApiOperation({ summary: 'Search companies by name' }) - async search(@Query('name') name: string): Promise { + @Get("search") + @ApiOperation({ summary: "Search companies by name" }) + async search(@Query("name") name: string): Promise { const companies = await this.companiesService.findAllCompanies(); return companies .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) .map((c) => new ResponseCompanyDto(c)); } - @Get(':id') - @ApiOperation({ summary: 'Get company by ID' }) - async findById(@Param('id', ParseUUIDPipe) id: string): Promise { + @Get(":id") + @ApiOperation({ summary: "Get company by ID" }) + async findById( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { const company = await this.companiesService.findCompanyById(id); return new ResponseCompanyDto(company); } - @Patch(':id') + @Patch(":id") @FreightAdmin() - @ApiOperation({ summary: 'Update a company' }) + @ApiOperation({ summary: "Update a company" }) async update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, ): Promise { const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } - @Delete(':id') + @Delete(":id") @FreightAdmin() - @ApiOperation({ summary: 'Soft-delete a company' }) + @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } - @Post(':companyId/documents') + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a company (onboarding)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, 'companies', files); + return this.filesService.uploadMany(companyId, "companies", files); } - @Post(':companyId/profiles') + @Post(":companyId/profiles") @FreightAdmin() - @ApiOperation({ summary: 'Add a profile (employee) to a company' }) + @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @Body() dto: CreateExternalProfileDto, ): Promise { - const profile = await this.companiesService.createProfile({ ...dto, companyId }); + const profile = await this.companiesService.createProfile({ + ...dto, + companyId, + }); return new ResponseExternalProfileDto(profile); } - @Get(':companyId/profiles') - @ApiOperation({ summary: 'List profiles for a company' }) + @Get(":companyId/profiles") + @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ): Promise { - const profiles = await this.companiesService.findProfilesByCompany(companyId); + const profiles = + await this.companiesService.findProfilesByCompany(companyId); return profiles.map((p) => new ResponseExternalProfileDto(p)); } - @Get('profile/user/:userId') - @ApiOperation({ summary: 'Get profile by IAM user ID' }) + @Get("profile/user/:userId") + @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( - @Param('userId', ParseUUIDPipe) userId: string, + @Param("userId", ParseUUIDPipe) userId: string, ): Promise { const profile = await this.companiesService.findProfileByUserId(userId); return new ResponseExternalProfileDto(profile); } - - @Post('ff-clients') - @FreightAdmin() - @ApiOperation({ summary: 'Link a forwarder to a client company' }) - async createFFClient(@Body() dto: CreateFFClientDto): Promise { - const client = await this.companiesService.createFFClient(dto); - return new ResponseFFClientDto(client); - } - - @Get(':forwarderCompanyId/clients') - @ApiOperation({ summary: 'List clients of a forwarder' }) - async listFFClients( - @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string, - ): Promise { - const clients = await this.companiesService.findForwarderClients(forwarderCompanyId); - return clients.map((c) => new ResponseFFClientDto(c)); - } - - @Delete('ff-clients/:id') - @FreightAdmin() - @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) - @HttpCode(HttpStatus.NO_CONTENT) - async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { - await this.companiesService.deleteFFClient(id); - } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 406c4f509..53d3de4c8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,21 +1,30 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { FilesModule } from '../files/files.module'; -import { CompaniesController } from './companies.controller'; -import { CompaniesService } from './companies.service'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; -import { Booking } from '../bookings/entities/booking.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { FilesModule } from "../files/files.module"; +import { CompaniesController } from "./companies.controller"; +import { CompaniesService } from "./companies.service"; +import { CompaniesRepository } from "./companies.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { CompanyProfile } from "./entities/company-profile.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { CompanyProfileRepository } from "./company-profile.repository"; @Module({ - imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule], + imports: [ + TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + FilesModule, + ], controllers: [CompaniesController], - providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository], + providers: [ + CompaniesService, + CompaniesRepository, + ExternalProfileRepository, + CompanyProfileRepository, + CompanyDashboardRepository, + ], exports: [CompaniesService], }) -export class CompaniesModule {} +export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 086e8d767..fe1bc5598 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,19 +1,27 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import { CompaniesRepository } from "./companies.repository"; +import { CompanyProfileRepository } from "./company-profile.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { + CompanyProfile, + ProfileType, + ProfileStatus, +} from "./entities/company-profile.entity"; export interface UserIdentity { userId: string; @@ -27,10 +35,10 @@ export interface UserIdentity { export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, + private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, - private readonly ffClientsRepo: FFClientRepository, private readonly dashboardRepo: CompanyDashboardRepository, - ) {} + ) { } async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); @@ -40,27 +48,33 @@ export class CompaniesService { return this.companiesRepo.create(dto); } - async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> { + async createCompanyWithProfile( + identity: UserIdentity, + dto: CreateCompanyWithProfileDto, + ): Promise<{ company: Company; profile: ExternalProfile }> { if (dto.tin) { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { - throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); } } const existingProfile = await this.profilesRepo.findByEmail(identity.email); if (existingProfile) { - throw new ConflictException(`Profile with email ${identity.email} already exists`); + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); } const company = await this.companiesRepo.create({ name: dto.companyName, type: dto.companyType, - tin: dto.tin ?? '', + tin: dto.tin ?? "", vatNumber: dto.vatNumber ?? null, - businessLicense: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null, - country: dto.companyLocation ?? 'Ethiopia', + country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, phone: dto.companyPhone ?? null, email: dto.companyEmail ?? null, @@ -78,11 +92,39 @@ export class CompaniesService { isPrimaryContact: dto.isPrimaryContact ?? true, }); + // Persist the operational role(s) chosen during onboarding. Types are + // already constrained to the company type on the client; any that don't + // match are skipped defensively rather than failing the whole signup. + if (dto.companyProfiles?.length) { + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + for (const input of dto.companyProfiles) { + if (!allowedTypes.includes(input.type)) continue; + const existing = await this.companyProfilesRepo.findByType( + company.id, + input.type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference( + input.type, + ); + await this.companyProfilesRepo.create({ + companyId: company.id, + type: input.type, + reference, + businessLicense: input.businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); + } + return { company, profile }; } async findAllCompanies(): Promise { - return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + return this.companiesRepo.findAll({ order: { name: "ASC" } }); } async findCompanyById(id: string): Promise { @@ -91,12 +133,21 @@ export class CompaniesService { return company; } - async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> { + async getCompanyInfoByUserId( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); const company = profile.company; - if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`); + if (!company) + throw new NotFoundException( + `Company for profile ${profile.id} not found`, + ); + + company.companyProfiles = + await this.companyProfilesRepo.findByCompanyId(company.id); return { profile, company }; } @@ -114,7 +165,9 @@ export class CompaniesService { * column, so "delivered YTD" counts bookings created this year that reached a * delivered/completed status. */ - async getDashboardSummary(userId: string): Promise { + async getDashboardSummary( + userId: string, + ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. const profile = await this.profilesRepo.findByUserId(userId); @@ -125,7 +178,9 @@ export class CompaniesService { const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); // Same point in the previous year, so YoY compares like-for-like windows. - const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime())); + const prevYearToDate = new Date( + prevYearStart.getTime() + (now.getTime() - yearStart.getTime()), + ); const [ deliveredThis, @@ -139,20 +194,36 @@ export class CompaniesService { this.dashboardRepo.countDelivered(companyId, yearStart, now), this.dashboardRepo.countCommitted(companyId, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate), + this.dashboardRepo.sumPaidSpendByCurrency( + companyId, + prevYearStart, + prevYearToDate, + ), this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), - this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate), - this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now), + this.dashboardRepo.sumCommittedTonnage( + companyId, + prevYearStart, + prevYearToDate, + ), + this.dashboardRepo.monthlyCommittedTonnage( + companyId, + this.monthsAgo(now, 5), + now, + ), ]); // Spend can span currencies; report the dominant one (prefer ETB on ties). const spend = this.pickCurrencyTotal(spendThisByCcy); - const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; + const spendPrev = + spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; return { deliveredCount: deliveredThis, // Share of committed bookings that reached delivered/completed. - completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0, + completionRate: + committedThis > 0 + ? Math.round((deliveredThis / committedThis) * 100) + : 0, spendYtd: spend.total, spendCurrency: spend.currency, spendYtdChangePct: this.changePct(spend.total, spendPrev), @@ -172,12 +243,12 @@ export class CompaniesService { deliveredCount: 0, completionRate: 0, spendYtd: 0, - spendCurrency: 'ETB', + spendCurrency: "ETB", spendYtdChangePct: 0, freightVolume: { totalTonnes: 0, totalValue: 0, - currency: 'ETB', + currency: "ETB", ytdChangePct: 0, monthly: this.buildMonthlySeries(now, []), }, @@ -190,8 +261,11 @@ export class CompaniesService { } /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ - private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } { - if (totals.length === 0) return { currency: 'ETB', total: 0 }; + private pickCurrencyTotal(totals: { currency: string; total: number }[]): { + currency: string; + total: number; + } { + if (totals.length === 0) return { currency: "ETB", total: 0 }; return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); } @@ -206,13 +280,29 @@ export class CompaniesService { now: Date, rows: { year: number; month: number; tonnes: number }[], ): { month: string; tonnes: number }[] { - const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); const series: { month: string; tonnes: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; - series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) }); + series.push({ + month: labels[d.getMonth()], + tonnes: Math.round(byKey.get(key) ?? 0), + }); } return series; } @@ -224,7 +314,10 @@ export class CompaniesService { return updated; } - async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); const companyUpdates: Record = {}; @@ -233,30 +326,38 @@ export class CompaniesService { if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; - if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; - if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.companyLocation !== undefined) + companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) + companyUpdates.address = dto.companyAddress; if (dto.tin !== undefined) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { - companyUpdates.businessLicense = dto.fanNumber; companyUpdates.fanNumber = dto.fanNumber; } - if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; - if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; - if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.contactPersonName !== undefined) + attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) + attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) + attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) + attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) + attrUpdates.generalManagerPhone = dto.generalManagerPhone; if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) + attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); return new ProfileResponseDto(profile, updated); } @@ -270,7 +371,9 @@ export class CompaniesService { const existing = await this.profilesRepo.findByEmail(dto.email); if (existing) { - throw new ConflictException(`Profile with email ${dto.email} already exists`); + throw new ConflictException( + `Profile with email ${dto.email} already exists`, + ); } return this.profilesRepo.create(dto); @@ -278,7 +381,8 @@ export class CompaniesService { async findProfileByUserId(userId: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); return profile; } @@ -286,28 +390,119 @@ export class CompaniesService { return this.profilesRepo.findByCompanyId(companyId); } - async createFFClient(dto: CreateFFClientDto): Promise { - await this.findCompanyById(dto.forwarderCompanyId); - await this.findCompanyById(dto.clientCompanyId); + private getProfileTypeForCompanyType(companyType: string): ProfileType[] { + switch (companyType) { + case "customer": + return [ProfileType.importer, ProfileType.exporter]; + case "freight_forwarder": + return [ProfileType.freightForwarder]; + case "dj_freight_forwarder": + return [ProfileType.djFreightForwarder]; + case "transporter": + return [ProfileType.transporter]; + default: + return []; + } + } - const existing = await this.ffClientsRepo.findRelationship( - dto.forwarderCompanyId, - dto.clientCompanyId, - ); - if (existing) { - throw new ConflictException('This forwarder-client relationship already exists'); + async createCompanyProfile( + companyId: string, + profileType?: ProfileType, + ): Promise { + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + const type = profileType ?? allowedTypes[0]; + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); } - return this.ffClientsRepo.create(dto); + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (existing) { + throw new ConflictException( + `Company already has a ${type} profile (${existing.reference})`, + ); + } + + const reference = await this.companyProfilesRepo.generateReference(type); + + return this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); } - async findForwarderClients(forwarderCompanyId: string): Promise { - return this.ffClientsRepo.findByForwarder(forwarderCompanyId); + async createDefaultProfilesForCompany( + companyId: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const types = this.getProfileTypeForCompanyType(company.type); + + const profiles: CompanyProfile[] = []; + for (const type of types) { + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (!existing) { + profiles.push(await this.createCompanyProfile(companyId, type)); + } + } + + if (profiles.length === 0) { + throw new BadRequestException( + `Company of type "${company.type}" must have at least one operational profile`, + ); + } + + return profiles; } - async deleteFFClient(id: string): Promise { - const client = await this.ffClientsRepo.findById(id); - if (!client) throw new NotFoundException(`FFClient ${id} not found`); - await this.ffClientsRepo.softDelete(id); + /** + * Add operational profile(s) to the current user's company (portal settings). + * Add-only and idempotent: each requested type must be allowed for the + * company's type, profiles that already exist are skipped (not re-created or + * rejected), and the full updated list is returned. + */ + async addCompanyProfilesForUser( + userId: string, + types: ProfileType[], + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + for (const type of types) { + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + return this.companyProfilesRepo.findByCompanyId(companyId); } } diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts new file mode 100644 index 000000000..db7427112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; + +const SEQUENCE_MAP: Record = { + [ProfileType.exporter]: "seq_company_profile_ex", + [ProfileType.importer]: "seq_company_profile_im", + [ProfileType.freightForwarder]: "seq_company_profile_ffe", + [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", + [ProfileType.transporter]: "seq_company_profile_tr", +}; + +const PREFIX_MAP: Record = { + [ProfileType.exporter]: "EX", + [ProfileType.importer]: "IM", + [ProfileType.freightForwarder]: "FFE", + [ProfileType.djFreightForwarder]: "FWJ", + [ProfileType.transporter]: "TR", +}; + +@Injectable() +export class CompanyProfileRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyProfile) + repo: Repository, + ) { + super(repo); + } + + async generateReference(type: ProfileType): Promise { + const seqName = SEQUENCE_MAP[type]; + const result = await this.repository.query( + `SELECT nextval('${seqName}') AS next_id`, + ); + const nextId = result[0].next_id as number; + const prefix = PREFIX_MAP[type]; + return `${prefix}-${String(nextId).padStart(5, "0")}`; + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + relations: ["company"], + }); + } + + async findByType( + companyId: string, + type: ProfileType, + ): Promise { + return this.repository.findOne({ + where: { companyId, type }, + }); + } + + async findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts new file mode 100644 index 000000000..838c42111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class AddCompanyProfilesDto { + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + types!: ProfileType[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 4287676d6..aa0bb72a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -1,5 +1,17 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} export class CreateCompanyWithProfileDto { @IsEnum(CompanyType) @@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto { @IsOptional() attributes?: Record; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CompanyProfileInputDto) + companyProfiles?: CompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index b22334697..5718f541e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -25,11 +25,6 @@ export class CreateCompanyDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(100) - businessLicense?: string; - @IsOptional() @IsString() @MaxLength(32) diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts deleted file mode 100644 index 46375d7ea..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator'; -import { FFClientRelationship } from '../entities/ff-client.entity'; - -export class CreateFFClientDto { - @IsUUID() - @IsNotEmpty() - forwarderCompanyId!: string; - - @IsUUID() - @IsNotEmpty() - clientCompanyId!: string; - - @IsOptional() - @IsEnum(FFClientRelationship) - relationshipType?: FFClientRelationship; - - @IsOptional() - @IsBoolean() - canBookOnBehalf?: boolean; - - @IsOptional() - @IsBoolean() - canViewDocuments?: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index ee8ede34f..d6744e75f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,9 +1,11 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { companyId: string; companyName: string; + companyType: string; companyEmail: string | null; companyPhone: string | null; companyLocation: string; @@ -12,6 +14,8 @@ export class ProfileResponseDto { vatNumber: string | null; fanNumber: string | null; + companyProfiles: ResponseCompanyProfileDto[]; + contactPersonName: string | null; contactPersonPhone: string | null; generalManagerName: string | null; @@ -29,6 +33,10 @@ export class ProfileResponseDto { constructor(profile: ExternalProfile, company: Company) { this.companyId = company.id; this.companyName = company.name; + this.companyType = company.type; + this.companyProfiles = + company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? + []; this.companyEmail = company.email ?? null; this.companyPhone = company.phone ?? null; this.companyLocation = company.country; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 1879e25d6..cb7777e8b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,6 +1,29 @@ import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { CompanyProfile } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +export class ResponseCompanyProfileDto { + id: string; + type: string; + reference: string; + status: string; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: Date; + updatedAt: Date; + + constructor(profile: CompanyProfile) { + this.id = profile.id; + this.type = profile.type; + this.reference = profile.reference; + this.status = profile.status; + this.businessLicense = profile.businessLicense; + this.attributes = profile.attributes; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} + export class ResponseCompanyDto { id: string; name: string; @@ -8,7 +31,6 @@ export class ResponseCompanyDto { status: CompanyStatus; tin: string; vatNumber?: string | null; - businessLicense?: string | null; fanNumber?: string | null; country: string; address?: string | null; @@ -17,6 +39,7 @@ export class ResponseCompanyDto { website?: string | null; attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; + companyProfiles?: ResponseCompanyProfileDto[]; createdAt: Date; updatedAt: Date; @@ -27,7 +50,6 @@ export class ResponseCompanyDto { this.status = company.status; this.tin = company.tin; this.vatNumber = company.vatNumber; - this.businessLicense = company.businessLicense; this.fanNumber = company.fanNumber; this.country = company.country; this.address = company.address; @@ -36,6 +58,7 @@ export class ResponseCompanyDto { this.website = company.website; this.attributes = company.attributes; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts deleted file mode 100644 index 44a48069b..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FFClient, FFClientRelationship } from '../entities/ff-client.entity'; - -export class ResponseFFClientDto { - id: string; - forwarderCompanyId: string; - clientCompanyId: string; - relationshipType: FFClientRelationship; - canBookOnBehalf: boolean; - canViewDocuments: boolean; - createdAt: Date; - updatedAt: Date; - - constructor(client: FFClient) { - this.id = client.id; - this.forwarderCompanyId = client.forwarderCompanyId; - this.clientCompanyId = client.clientCompanyId; - this.relationshipType = client.relationshipType; - this.canBookOnBehalf = client.canBookOnBehalf; - this.canViewDocuments = client.canViewDocuments; - this.createdAt = client.createdAt; - this.updatedAt = client.updatedAt; - } -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts deleted file mode 100644 index a7ace689d..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateFFClientDto } from './create-ff-client.dto'; - -export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts new file mode 100644 index 000000000..84da76135 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +export enum ProfileType { + importer = "importer", + exporter = "exporter", + freightForwarder = "freight_forwarder", + djFreightForwarder = "dj_freight_forwarder", + transporter = "transporter", +} + +export enum ProfileStatus { + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", +} + +@Entity({ schema: "freight", name: "company_profiles" }) +@Index(["reference"], { unique: true }) +@Index(["type"]) +@Index(["companyId"]) +export class CompanyProfile extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.companyProfiles) + @JoinColumn({ name: "company_id" }) + company!: Company; + + @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) + type!: ProfileType; + + @Column({ + name: "reference", + type: "varchar", + length: 20, + nullable: false, + unique: true, + }) + reference!: string; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: ProfileStatus.Active, + }) + status!: ProfileStatus; + + @Column({ + name: "business_license", + type: "varchar", + length: 100, + nullable: true, + }) + businessLicense?: string | null; + + @Column({ name: "attributes", type: "jsonb", nullable: true }) + attributes?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 070854eb8..ec578a3b7 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -1,79 +1,110 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; -import { ExternalProfile } from './external-profile.entity'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; +import { ExternalProfile } from "./external-profile.entity"; +import { CompanyProfile } from "./company-profile.entity"; export enum CompanyType { - Customer = 'customer', - Forwarder = 'forwarder', - Transporter = 'transporter', - Broker = 'broker', + Customer = "customer", + FreightForwarder = "freight_forwarder", + DJFreightForwarder = "dj_freight_forwarder", + Transporter = "transporter", } export enum CompanyStatus { - Active = 'active', - Pending = 'pending', - Suspended = 'suspended', - Blacklisted = 'blacklisted', + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", } -@Entity({ schema: 'freight', name: 'companies' }) -@Index(['tin']) -@Index(['type']) +@Entity({ schema: "freight", name: "companies" }) +@Index(["tin"]) +@Index(["type"]) export class Company extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 200 }) + @Column({ name: "name", type: "varchar", length: 200 }) name!: string; - @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType }) + @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) type!: CompanyType; - @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending }) + @Column({ + name: "status", + type: "varchar", + length: 32, + default: CompanyStatus.Pending, + }) status!: CompanyStatus; - @Column({ name: 'tin', type: 'varchar', length: 10, unique: true }) + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + @Column({ name: "vat_number", type: "varchar", length: 50, nullable: true }) vatNumber?: string | null; - @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true }) - businessLicense?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true }) + @Column({ name: "fan_number", type: "varchar", length: 16, nullable: true }) fanNumber?: string | null; - @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' }) + @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" }) country!: string; - @Column({ name: 'address', type: 'text', nullable: true }) + @Column({ name: "address", type: "text", nullable: true }) address?: string | null; - @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + @Column({ name: "phone", type: "varchar", length: 20, nullable: true }) phone?: string | null; - @Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) email?: string | null; - @Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "contact_person_name", + type: "varchar", + length: 100, + nullable: true, + }) contactPersonName?: string | null; - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "contact_person_phone", + type: "varchar", + length: 20, + nullable: true, + }) contactPersonPhone?: string | null; - @Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "general_manager_name", + type: "varchar", + length: 100, + nullable: true, + }) generalManagerName?: string | null; - @Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true }) + @Column({ + name: "general_manager_email", + type: "varchar", + length: 150, + nullable: true, + }) generalManagerEmail?: string | null; - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "general_manager_phone", + type: "varchar", + length: 20, + nullable: true, + }) generalManagerPhone?: string | null; - @Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) + @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; - @Column({ name: 'attributes', type: 'jsonb', nullable: true }) + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; + + @OneToMany(() => CompanyProfile, (profile) => profile.company) + companyProfiles?: CompanyProfile[]; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts deleted file mode 100644 index 136dea277..000000000 --- a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm'; -import { Company } from './company.entity'; - -export enum FFClientRelationship { - ManagedAccount = 'managed_account', - SubAgent = 'sub_agent', -} - -@Entity({ schema: 'freight', name: 'ff_clients' }) -@Unique(['forwarderCompanyId', 'clientCompanyId']) -@Index(['forwarderCompanyId']) -@Index(['clientCompanyId']) -export class FFClient extends BaseEntity { - @Column({ name: 'forwarder_company_id', type: 'uuid' }) - forwarderCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'forwarder_company_id' }) - forwarderCompany!: Company; - - @Column({ name: 'client_company_id', type: 'uuid' }) - clientCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'client_company_id' }) - clientCompany!: Company; - - @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount }) - relationshipType!: FFClientRelationship; - - @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true }) - canBookOnBehalf!: boolean; - - @Column({ name: 'can_view_documents', type: 'boolean', default: true }) - canViewDocuments!: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts deleted file mode 100644 index b94cedec6..000000000 --- a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { BaseRepository } from '@edr/api-common'; -import { FFClient } from './entities/ff-client.entity'; - -@Injectable() -export class FFClientRepository extends BaseRepository { - constructor( - @InjectRepository(FFClient) - repo: Repository, - ) { - super(repo); - } - - async findByForwarder(forwarderCompanyId: string): Promise { - return this.repository.find({ where: { forwarderCompanyId } as any }); - } - - async findByClient(clientCompanyId: string): Promise { - return this.repository.find({ where: { clientCompanyId } as any }); - } - - async findRelationship( - forwarderCompanyId: string, - clientCompanyId: string, - ): Promise { - return this.repository.findOne({ - where: { forwarderCompanyId, clientCompanyId } as any, - }); - } -} diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts deleted file mode 100644 index 7451bd6b6..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ /dev/null @@ -1,86 +0,0 @@ -// src/modules/customers/customers.controller.ts - -import { - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Body, - Query, -} from "@nestjs/common"; - -import { ApiOperation } from "@nestjs/swagger"; - -import { FreightAdmin } from "../../common/booking-guards"; -import { CustomersService } from "./customers.service"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Controller("customers") -@FreightAdmin() -export class CustomersController { - constructor(private readonly customersService: CustomersService) {} - - @Post() - create(@Body() createCustomerDto: CreateCustomerDto): Promise { - return this.customersService.create(createCustomerDto); - } - - @Get() - findAll(): Promise { - return this.customersService.findAll(); - } - - @Get("stats") - @ApiOperation({ summary: "Get customer statistics" }) - getStats(): Promise<{ total: number; withVatNumber: number }> { - return this.customersService.getStats(); - } - - @Get("search") - searchByName(@Query("name") name: string): Promise { - return this.customersService.searchByName(name); - } - - @Get("email/:email") - findByEmail(@Param("email") email: string): Promise { - return this.customersService.findByEmail(email); - } - - @Get("vat/:vatNumber") - findByVatNumber(@Param("vatNumber") vatNumber: string): Promise { - return this.customersService.findByVatNumber(vatNumber); - } - - @Get(":id") - findById(@Param("id", ParseUUIDPipe) id: string): Promise { - return this.customersService.findById(id); - } - - // @Get("user/:userId") - // findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise { - // return this.customersService.findByUserId(userId); - // } - - @Patch(":id") - @ApiOperation({ summary: "Update a customer" }) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateCustomerDto, - ): Promise { - return this.customersService.update(id, dto); - } - - @Delete(":id") - @ApiOperation({ summary: "Soft-delete a customer" }) - @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param("id", ParseUUIDPipe) id: string): Promise { - return this.customersService.delete(id); - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.module.ts b/apps/edr-freight-api/src/modules/customers/customers.module.ts deleted file mode 100644 index 28c6b7c89..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { CustomersController } from "./customers.controller"; -import { CustomersRepository } from "./customers.repository"; -import { CustomersService } from "./customers.service"; -import { Customer } from "./entities/customer.entity"; - -@Module({ - imports: [TypeOrmModule.forFeature([Customer])], - controllers: [CustomersController], - providers: [CustomersService, CustomersRepository], - exports: [CustomersService], -}) -export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts deleted file mode 100644 index 5933cef61..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ /dev/null @@ -1,117 +0,0 @@ -// import { BaseRepository } from "@edr/api-common"; -// import { EntityRepository } from "typeorm"; - -// src/modules/customers/customers.repository.ts -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm"; -import { Customer } from "./entities/customer.entity"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -// import { UpdateCustomerDto } from "./dto/update-customer.dto"; - -@Injectable() -export class CustomersRepository { - constructor( - @InjectRepository(Customer) - private readonly repository: Repository, - ) { } - - async create(dto: CreateCustomerDto): Promise { - const customer = this.repository.create(dto); - return await this.repository.save(customer); - } - - async findAll(options?: FindManyOptions): Promise { - return await this.repository.find(options); - } - - async findById(id: string): Promise { - return await this.repository.findOne({ where: { id } as FindOptionsWhere }); - } - - async findByUserId(userId: string): Promise { - return await this.repository.findOne({ where: { userId } as FindOptionsWhere }); - } - - async findByEmail(email: string): Promise { - return await this.repository.findOne({ where: { email } as FindOptionsWhere }); - } - - async findByVatNumber(vatNumber: string): Promise { - return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere }); - } - - async findByName(name: string): Promise { - return await this.repository - .createQueryBuilder("customer") - .where("customer.companyName ILIKE :name", { name: `%${name}%` }) - .getMany(); - } - - async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise { - if (!email && !vatNumber) return null; - - const queryBuilder = this.repository.createQueryBuilder('customer'); - - if (email && vatNumber) { - queryBuilder.where('customer.email = :email', { email }) - .orWhere('customer.vatNumber = :vatNumber', { vatNumber }); - } else if (email) { - queryBuilder.where('customer.email = :email', { email }); - } else if (vatNumber) { - queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber }); - } - - return await queryBuilder.getOne(); - } - - async update(id: string, updates: Partial): Promise { - await this.repository.update(id, updates); - return this.findById(id); - } - - async delete(id: string): Promise { - const result = await this.repository.delete(id); - return (result.affected ?? 0) > 0; - } - - async count(where?: any): Promise { - if (where?.createdAt) { - const result = await this.repository - .createQueryBuilder('customer') - .where('customer.createdAt >= :date', { date: where.createdAt }) - .getCount(); - return result; - } - return await this.repository.count(); - } - - async existsByUniqueFields(email: string, vatNumber?: string): Promise { - const queryBuilder = this.repository.createQueryBuilder('customer') - .where('customer.email = :email', { email }); - - if (vatNumber) { - queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber }); - } - - const count = await queryBuilder.getCount(); - return count > 0; - } - - async countWithVatNumber(): Promise { - const count = await this.repository - .createQueryBuilder('customer') - .where('customer.vatNumber IS NOT NULL') - .andWhere("customer.vatNumber != ''") - .getCount(); - - return count; - } - - getRepository(): Repository { - return this.repository; - } - softDelete(id: string): any { - return id; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts deleted file mode 100644 index e3d1f3a82..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - Injectable, - NotFoundException, - ConflictException, - BadRequestException, -} from "@nestjs/common"; - -import { CustomersRepository } from "./customers.repository"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersService { - constructor(private readonly customersRepository: CustomersRepository) {} - - /** Create a new customer */ - async create(dto: CreateCustomerDto): Promise { - const exists = await this.customersRepository.existsByUniqueFields( - dto.email, - dto.vatNumber, - ); - - if (exists) { - throw new ConflictException( - "Customer with same email or VAT number already exists", - ); - } - - if (dto.vatNumber && dto.vatNumber.length !== 10) { - throw new BadRequestException("VAT number must be exactly 10 digits"); - } - - return this.customersRepository.create(dto); - } - - /** Get all customers */ - findAll(): Promise { - return this.customersRepository.findAll({ - order: { companyName: "ASC" }, - }); - } - - /** Get customer by ID */ - async findById(id: string): Promise { - const customer = await this.customersRepository.findById(id); - - if (!customer) { - throw new NotFoundException(`Customer with ID ${id} not found`); - } - - return customer; - } - - // async findByUserId(userId: string): Promise { - // const customer = await this.customersRepository.findByUserId(userId); - - // if (!customer) { - // throw new NotFoundException(`Customer with ID ${userId} not found`); - // } - - // return customer; - //} - - /** Get customer by email */ - async findByEmail(email: string): Promise { - const customer = await this.customersRepository.findByEmail(email); - - if (!customer) { - throw new NotFoundException(`Customer with email ${email} not found`); - } - - return customer; - } - - /** Get customer by VAT number */ - async findByVatNumber(vatNumber: string): Promise { - const customer = await this.customersRepository.findByVatNumber(vatNumber); - - if (!customer) { - throw new NotFoundException( - `Customer with VAT number ${vatNumber} not found`, - ); - } - - return customer; - } - - /** Search customers by name */ - searchByName(name: string): Promise { - return this.customersRepository.findByName(name); - } - - /** Update customer */ - async update(id: string, dto: UpdateCustomerDto): Promise { - await this.findById(id); - - // Validate VAT number if provided - if (dto.vatNumber && dto.vatNumber.length !== 10) { - throw new BadRequestException("VAT number must be exactly 10 digits"); - } - - // // Check email conflict - // if (dto.email) { - // const existing = await this.customersRepository.findByEmail(dto.email); - - // // if (existing && existing.userId !== id) { - // // throw new ConflictException( - // // `Customer with email "${dto.email}" already exists`, - // // ); - // // } - // } - - const updated = await this.customersRepository.update(id, dto); - - if (!updated) { - throw new NotFoundException(`Customer ${id} not found`); - } - - return updated; - } - - /** Delete customer (soft delete) */ - async remove(id: string): Promise { - await this.findById(id); - await this.customersRepository.softDelete(id); - } - - /** Get customer statistics */ - async getStats(): Promise<{ total: number; withVatNumber: number }> { - const total = await this.customersRepository.count(); - const withVatNumber = await this.customersRepository.countWithVatNumber(); - - return { total, withVatNumber }; - } - - delete(id: string): any { - return id; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts deleted file mode 100644 index 39fc16414..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - IsEmail, - IsEnum, - IsOptional, - IsString, - MaxLength, - IsNotEmpty, - Length, - Matches, -} from "class-validator"; - -// Enums -export enum CustomerStatusDto { - Active = "Active", - Pending = "Pending", - Inactive = "Inactive", -} - -export enum CustomerTypeDto { - Importer = "Importer", - Exporter = "Exporter", - Supplier = "Supplier", -} - -// DTO -export class CreateCustomerDto { - // Basic identity - @IsString() - @IsNotEmpty() - userId!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - firstName!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - lastName!: string; - - @IsEmail() - @IsNotEmpty() - email!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - phone!: string; - - // Company info - @IsString() - @IsNotEmpty() - @MaxLength(200) - companyName!: string; - - @IsEmail() - @IsNotEmpty() - companyEmail!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - companyPhone!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - companyLocation!: string; - - @IsString() - @IsNotEmpty() - companyAddress!: string; - - // Classification - @IsOptional() - @IsEnum(CustomerTypeDto) - customerType?: CustomerTypeDto; - - @IsOptional() - @IsEnum(CustomerStatusDto) - status?: CustomerStatusDto; - - // Legal identifiers - @IsString() - @IsNotEmpty() - @Length(10, 10) - @Matches(/^\d+$/, { message: "TIN must contain only digits" }) - tinNumber!: string; - - @IsString() - @IsNotEmpty() - @Length(16, 16) - @Matches(/^\d+$/, { message: "FAN must contain only digits" }) - fanNumber!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(50) - vatNumber!: string; - - // Contact person - @IsString() - @IsNotEmpty() - @MaxLength(100) - contactPersonName!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - contactPersonPhone!: string; - - // Management - @IsString() - @IsNotEmpty() - @MaxLength(100) - generalManagerName!: string; - - @IsEmail() - @IsNotEmpty() - generalManagerEmail!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - generalManagerPhone!: string; - - // POA (Power of Attorney) - @IsOptional() - @IsString() - @MaxLength(100) - poaName?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - poaPhone?: string; - - @IsOptional() - @IsString() - poaAddress?: string; - - @IsOptional() - @IsEmail() - poaEmail?: string; - - @IsOptional() - @IsString() - @MaxLength(100) - poaLocation?: string; - - // Extra - @IsOptional() - @IsString() - notes?: string; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts deleted file mode 100644 index 3d2a086d4..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -// src/modules/customers/dto/response-customer.dto.ts -import { Customer } from '../entities/customer.entity'; - -export class ResponseCustomerDto { - //UserId: string; - firstName: string; - lastName: string; - email: string; - phone: string; - companyName: string; - companyEmail: string; - companyPhone: string; - companyLocation: string; - companyAddress: string; - contactPersonName: string; - contactPersonPhone: string; - tinNumber: string; - vatNumber?: string; - fanNumber: string; - generalManagerName: string; - generalManagerEmail: string; - generalManagerPhone: string; - poaName?: string; - poaPhone?: string; - poaAddress?: string; - poaEmail?: string; - poaLocation?: string; - notes?: string; - createdAt: Date; - updatedAt: Date; - - constructor(customer: Customer) { - //this.UserId = customer.userId; - this.firstName = customer.firstName; - this.lastName = customer.lastName; - this.email = customer.email; - this.phone = customer.phone; - this.companyName = customer.companyName; - this.companyEmail = customer.companyEmail; - this.companyPhone = customer.companyPhone; - this.companyLocation = customer.companyLocation; - this.companyAddress = customer.companyAddress; - this.contactPersonName = customer.contactPersonName; - this.contactPersonPhone = customer.contactPersonPhone; - this.tinNumber = customer.tinNumber; - this.vatNumber = customer.vatNumber ?? undefined; - this.fanNumber = customer.fanNumber; - this.generalManagerName = customer.generalManagerName; - this.generalManagerEmail = customer.generalManagerEmail; - this.generalManagerPhone = customer.generalManagerPhone; - this.poaName = customer.poaName ?? ''; - this.poaPhone = customer.poaPhone ?? ''; - this.poaAddress = customer.poaAddress ?? ''; - this.poaEmail = customer.poaEmail ?? ''; - this.poaLocation = customer.poaLocation ?? ''; - this.notes = customer.notes ?? ''; - this.createdAt = customer.createdAt; - this.updatedAt = customer.updatedAt; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts deleted file mode 100644 index f8cefe046..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -// src/modules/customers/dto/update-customer.dto.ts -import { PartialType } from '@nestjs/mapped-types'; -import { CreateCustomerDto } from './create-customer.dto'; - -export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { - email?: string; - vatNumber?: string; - // Add any other properties you need to access directly -} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts deleted file mode 100644 index abccbaef8..000000000 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -@Entity({ schema: 'freight', name: 'customers' }) -@Index(['email']) -//@Index(['userId']) -@Index(['tinNumber']) -@Index(['fanNumber']) -export class Customer extends BaseEntity { - //@Column({ name: 'user_id', type: 'uuid' }) - //userId!: string; - - @Column({ name: 'first_name', type: 'varchar', length: 100 }) - firstName!: string; - - @Column({ name: 'last_name', type: 'varchar', length: 100 }) - lastName!: string; - - @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) - email!: string; - - @Column({ name: 'phone', type: 'varchar', length: 20 }) - phone!: string; - - @Column({ name: 'company_name', type: 'varchar', length: 200 }) - companyName!: string; - - @Column({ name: 'company_email', type: 'varchar', length: 150 }) - companyEmail!: string; - - @Column({ name: 'company_phone', type: 'varchar', length: 20 }) - companyPhone!: string; - - @Column({ name: 'company_location', type: 'varchar', length: 100 }) - companyLocation!: string; - - @Column({ name: 'company_address', type: 'text' }) - companyAddress!: string; - - @Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true }) - customerType?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 32, nullable: true }) - status?: string | null; - - @Column({ name: 'contact_person_name', type: 'varchar', length: 100 }) - contactPersonName!: string; - - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20 }) - contactPersonPhone!: string; - - @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true }) - tinNumber!: string; - - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) - vatNumber?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true }) - fanNumber!: string; - - @Column({ name: 'general_manager_name', type: 'varchar', length: 100 }) - generalManagerName!: string; - - @Column({ name: 'general_manager_email', type: 'varchar', length: 150 }) - generalManagerEmail!: string; - - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 }) - generalManagerPhone!: string; - - @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true }) - poaName?: string | null; - - @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true }) - poaPhone?: string | null; - - @Column({ name: 'poa_address', type: 'text', nullable: true }) - poaAddress?: string | null; - - @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true }) - poaEmail?: string | null; - - @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true }) - poaLocation?: string | null; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts b/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts new file mode 100644 index 000000000..6a8776312 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts @@ -0,0 +1,18 @@ +import type { FacilityStatus, FacilityType } from '../entities/facility.entity'; + +export class CreateFacilityDto { + code!: string; + name!: string; + description?: string; + facilityType!: FacilityType; + facilityStatus?: FacilityStatus; + locationName?: string; + country?: string; + city?: string; + address?: string; + latitude?: number; + longitude?: number; + capacity?: number; + isActive?: boolean; + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts b/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts new file mode 100644 index 000000000..060aad95e --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts @@ -0,0 +1,18 @@ +import type { FacilityStatus, FacilityType } from '../entities/facility.entity'; + +export class UpdateFacilityDto { + code?: string; + name?: string; + description?: string; + facilityType?: FacilityType; + facilityStatus?: FacilityStatus; + locationName?: string; + country?: string; + city?: string; + address?: string; + latitude?: number; + longitude?: number; + capacity?: number; + isActive?: boolean; + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts b/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts new file mode 100644 index 000000000..a3d28c200 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { Warehouse } from '../../warehouses/entities/warehouse.entity'; + +export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const; +export type FacilityType = (typeof FACILITY_TYPES)[number]; + +export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const; +export type FacilityStatus = (typeof FACILITY_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'facilities' }) +@Index(['code'], { unique: true }) +@Index(['facilityStatus']) +export class Facility extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'facility_type', type: 'varchar', length: 32 }) + facilityType!: FacilityType; + + @Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' }) + facilityStatus!: FacilityStatus; + + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) + locationName?: string | null; + + @Column({ name: 'country', type: 'varchar', length: 100, nullable: true }) + country?: string | null; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city?: string | null; + + @Column({ name: 'address', type: 'text', nullable: true }) + address?: string | null; + + @Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true }) + latitude?: number | null; + + @Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true }) + longitude?: number | null; + + @Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacity?: number | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; + + @OneToMany(() => Warehouse, (warehouse) => warehouse.facility) + warehouses?: Warehouse[]; +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts new file mode 100644 index 000000000..25fbbc365 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateFacilityDto } from './dto/create-facility.dto'; +import { UpdateFacilityDto } from './dto/update-facility.dto'; +import { Facility } from './entities/facility.entity'; +import { FacilitiesService } from './facilities.service'; + +@ApiTags('Facilities') +@Controller('facilities') +export class FacilitiesController { + constructor(private readonly facilitiesService: FacilitiesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new facility' }) + async create(@Body() createFacilityDto: CreateFacilityDto): Promise { + return this.facilitiesService.create(createFacilityDto); + } + + @Get() + @ApiOperation({ summary: 'List all facilities' }) + async findAll(): Promise { + return this.facilitiesService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a facility by ID' }) + async findOne(@Param('id') id: string): Promise { + return this.facilitiesService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a facility' }) + async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise { + return this.facilitiesService.update(id, updateFacilityDto); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a facility (soft delete)' }) + async remove(@Param('id') id: string): Promise { + return this.facilitiesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.module.ts b/apps/edr-freight-api/src/modules/facilities/facilities.module.ts new file mode 100644 index 000000000..cf9cd2b9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Facility } from './entities/facility.entity'; +import { FacilitiesController } from './facilities.controller'; +import { FacilitiesRepository } from './facilities.repository'; +import { FacilitiesService } from './facilities.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Facility])], + controllers: [FacilitiesController], + providers: [FacilitiesService, FacilitiesRepository], + exports: [FacilitiesService, FacilitiesRepository], +}) +export class FacilitiesModule {} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts b/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts new file mode 100644 index 000000000..32b2df232 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Facility } from './entities/facility.entity'; + +@Injectable() +export class FacilitiesRepository extends BaseRepository { + constructor(@InjectRepository(Facility) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.service.ts b/apps/edr-freight-api/src/modules/facilities/facilities.service.ts new file mode 100644 index 000000000..5cc0d62d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import { CreateFacilityDto } from './dto/create-facility.dto'; +import { UpdateFacilityDto } from './dto/update-facility.dto'; +import { Facility } from './entities/facility.entity'; +import { FacilitiesRepository } from './facilities.repository'; + +@Injectable() +export class FacilitiesService { + constructor(private readonly facilitiesRepository: FacilitiesRepository) {} + + async create(createFacilityDto: CreateFacilityDto): Promise { + return this.facilitiesRepository.create(createFacilityDto); + } + + async findAll(): Promise { + return this.facilitiesRepository.findAll({ relations: ['warehouses'] }); + } + + async findOne(id: string): Promise { + return this.facilitiesRepository.findById(id); + } + + async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise { + return this.facilitiesRepository.update(id, updateFacilityDto); + } + + async remove(id: string): Promise { + return this.facilitiesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 25d32f106..6dcd9ad3e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -6,6 +6,9 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; export const LOCOMOTIVE_STATUSES = [ 'AVAILABLE', + 'UNAVAILABLE', + 'IMPORT_READY', + 'EXPORT_READY', 'ASSIGNED', 'MAINTENANCE', 'OUT_OF_SERVICE', diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 50893b626..87cdc62d5 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -1,25 +1,25 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; -import { OverviewController } from './overview.controller'; -import { OverviewRepository } from './overview.repository'; -import { OverviewService } from './overview.service'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { Company } from "../companies/entities/company.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; +import { OverviewController } from "./overview.controller"; +import { OverviewRepository } from "./overview.repository"; +import { OverviewService } from "./overview.service"; @Module({ imports: [ TypeOrmModule.forFeature([ Booking, PaymentEntity, - Customer, + Company, Train, Wagon, Container, @@ -31,4 +31,4 @@ import { OverviewService } from './overview.service'; controllers: [OverviewController], providers: [OverviewService, OverviewRepository], }) -export class OverviewModule {} +export class OverviewModule { } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 2c49ff8da..a57ba24db 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -1,24 +1,24 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; -import { Freight } from '@edr/types'; -import { Repository, ObjectLiteral } from 'typeorm'; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Freight } from "@edr/types"; +import { Repository, ObjectLiteral } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, -} from './overview.constants'; +} from "./overview.constants"; +import { Company } from "../companies/entities/company.entity"; export type OverviewBookingKpisRow = { totalActive: number; @@ -46,8 +46,8 @@ export class OverviewRepository { private readonly bookingRepository: Repository, @InjectRepository(PaymentEntity) private readonly paymentRepository: Repository, - @InjectRepository(Customer) - private readonly customerRepository: Repository, + @InjectRepository(Company) + private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, @InjectRepository(Wagon) @@ -60,32 +60,32 @@ export class OverviewRepository { private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, - ) {} + ) { } async getBookingKpis(): Promise { const row = await this.bookingRepository - .createQueryBuilder('booking') + .createQueryBuilder("booking") .select( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, - 'totalActive', + "totalActive", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, - 'needsAction', + "needsAction", ) .addSelect( `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, - 'urgent', + "urgent", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, - 'inApproval', + "inApproval", ) .addSelect( `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, - 'submittedToday', + "submittedToday", ) - .where('booking.deleted_at IS NULL') + .where("booking.deleted_at IS NULL") .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -112,9 +112,9 @@ export class OverviewRepository { const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = await Promise.all([ this.trainRepository - .createQueryBuilder('train') - .where('train.deleted_at IS NULL') - .andWhere('train.status IN (:...statuses)', { + .createQueryBuilder("train") + .where("train.deleted_at IS NULL") + .andWhere("train.status IN (:...statuses)", { statuses: [ Freight.TrainStatus.InService, Freight.TrainStatus.Scheduled, @@ -122,39 +122,46 @@ export class OverviewRepository { }) .getCount(), this.wagonRepository - .createQueryBuilder('wagon') - .where('wagon.deleted_at IS NULL') - .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .andWhere("wagon.status = :status", { + status: Freight.WagonStatus.Available, + }) .getCount(), this.containerRepository - .createQueryBuilder('container') - .where('container.deleted_at IS NULL') - .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) + .createQueryBuilder("container") + .where("container.deleted_at IS NULL") + .andWhere("container.status = :status", { status: "IN_TRANSIT" }) .getCount(), this.cargoRepository - .createQueryBuilder('cargo') - .where('cargo.deleted_at IS NULL') - .andWhere('cargo.status IN (:...statuses)', { - statuses: ['LOADED', 'IN_TRANSIT'], + .createQueryBuilder("cargo") + .where("cargo.deleted_at IS NULL") + .andWhere("cargo.status IN (:...statuses)", { + statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), ]); - return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; + return { + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + }; } async getCustomerKpis(): Promise<{ totalCustomers: number; newCustomersThisMonth: number; }> { - const row = await this.customerRepository - .createQueryBuilder('customer') - .select('COUNT(*)::int', 'totalCustomers') + const row = await this.companyRepository + .createQueryBuilder("customer") + .select("COUNT(*)::int", "totalCustomers") .addSelect( `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, - 'newCustomersThisMonth', + "newCustomersThisMonth", ) - .where('customer.deleted_at IS NULL') + .where("customer.deleted_at IS NULL") .getRawOne>(); return { @@ -170,26 +177,26 @@ export class OverviewRepository { successfulPaymentsMtd: number; }> { const revenueRow = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'revenueMtdEtb', + "revenueMtdEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'revenueMtdUsd', + "revenueMtdUsd", ) - .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') - .where('payment.status = :status', { status: 'success' }) + .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .getRawOne>(); const pendingPayments = await this.paymentRepository - .createQueryBuilder('payment') - .where('payment.status IN (:...statuses)', { - statuses: ['action-required', 'processing'], + .createQueryBuilder("payment") + .where("payment.status IN (:...statuses)", { + statuses: ["action-required", "processing"], }) .getCount(); @@ -201,7 +208,10 @@ export class OverviewRepository { }; } - async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { + async getStaffKpis(): Promise<{ + activeEmployees: number; + activeUsers: number; + }> { const [activeEmployees, activeUsers] = await Promise.all([ this.employeeRepository.count({ where: { isCurrent: true }, @@ -217,15 +227,17 @@ export class OverviewRepository { return { activeEmployees, activeUsers }; } - async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { + async getBookingTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('booking.created_at::date') - .orderBy('booking.created_at::date', 'ASC') + .groupBy("booking.created_at::date") + .orderBy("booking.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -236,11 +248,11 @@ export class OverviewRepository { async getStatusCounts(): Promise> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') - .groupBy('booking.status') + .createQueryBuilder("booking") + .select("booking.status", "status") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( @@ -252,26 +264,26 @@ export class OverviewRepository { days: number, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, - 'date', + "date", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'amountUsd', + "amountUsd", ) - .where('payment.status = :status', { status: 'success' }) + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) - .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ @@ -283,18 +295,18 @@ export class OverviewRepository { async getRecentBookings(limit: number): Promise { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select('booking.id', 'id') - .addSelect('booking.reference', 'reference') - .addSelect('COALESCE(company.name, \'—\')', 'customerLabel') - .addSelect('booking.status', 'status') - .addSelect('booking.priority_score', 'priorityScore') - .addSelect('booking.total_amount', 'totalAmount') - .addSelect('booking.payment_currency', 'paymentCurrency') - .addSelect('booking.created_at', 'createdAt') - .where('booking.deleted_at IS NULL') - .orderBy('booking.created_at', 'DESC') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select("booking.id", "id") + .addSelect("booking.reference", "reference") + .addSelect("COALESCE(company.name, '—')", "customerLabel") + .addSelect("booking.status", "status") + .addSelect("booking.priority_score", "priorityScore") + .addSelect("booking.total_amount", "totalAmount") + .addSelect("booking.payment_currency", "paymentCurrency") + .addSelect("booking.created_at", "createdAt") + .where("booking.deleted_at IS NULL") + .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ id: string; @@ -319,15 +331,17 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { + async getBookingsByFreightType(): Promise< + { label: string; count: number }[] + > { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.freight_type', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.freight_type", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.freight_type') - .orderBy('count', 'DESC') + .groupBy("booking.freight_type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -338,13 +352,13 @@ export class OverviewRepository { async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.payment_currency', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.payment_currency", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.payment_currency') - .orderBy('count', 'DESC') + .groupBy("booking.payment_currency") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -355,11 +369,11 @@ export class OverviewRepository { async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('payment.status') - .orderBy('count', 'DESC') + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -372,20 +386,25 @@ export class OverviewRepository { { method: string; count: number; amountEtb: number; amountUsd: number }[] > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.method', 'method') - .addSelect('COUNT(*)::int', 'count') + .createQueryBuilder("payment") + .select("payment.method", "method") + .addSelect("COUNT(*)::int", "count") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, - 'amountUsd', + "amountUsd", ) - .groupBy('payment.method') - .orderBy('count', 'DESC') - .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); + .groupBy("payment.method") + .orderBy("count", "DESC") + .getRawMany<{ + method: string; + count: string; + amountEtb: string; + amountUsd: string; + }>(); return rows.map((row) => ({ method: row.method, @@ -395,16 +414,18 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { + async getRevenueByCurrency(): Promise< + { currency: string; amount: number }[] + > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.currency', 'currency') - .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') - .where('payment.status = :status', { status: 'success' }) + .createQueryBuilder("payment") + .select("payment.currency", "currency") + .addSelect("COALESCE(SUM(payment.amount), 0)", "amount") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) - .groupBy('payment.currency') + .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ @@ -413,20 +434,28 @@ export class OverviewRepository { })); } - async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.trainRepository, 'train'); + async getTrainStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainRepository, "train"); } - async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.wagonRepository, 'wagon'); + async getWagonStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.wagonRepository, "wagon"); } - async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.containerRepository, 'container'); + async getContainerStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.containerRepository, "container"); } - async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.cargoRepository, 'cargo'); + async getCargoStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.cargoRepository, "cargo"); } private async statusBreakdown( @@ -435,11 +464,11 @@ export class OverviewRepository { ): Promise<{ status: string; count: number }[]> { const rows = await repository .createQueryBuilder(alias) - .select(`${alias}.status`, 'status') - .addSelect('COUNT(*)::int', 'count') + .select(`${alias}.status`, "status") + .addSelect("COUNT(*)::int", "count") .where(`${alias}.deleted_at IS NULL`) .groupBy(`${alias}.status`) - .orderBy('count', 'DESC') + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -448,15 +477,19 @@ export class OverviewRepository { })); } - async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('customer.created_at::date') - .orderBy('customer.created_at::date', 'ASC') + async getCustomerGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("customer.created_at::date") + .orderBy("customer.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -466,13 +499,16 @@ export class OverviewRepository { } async getCustomersByType(): Promise<{ label: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .groupBy('customer.customer_type') - .orderBy('count', 'DESC') + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select( + `COALESCE(NULLIF(customer.type, ''), 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .groupBy("customer.type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -481,16 +517,18 @@ export class OverviewRepository { })); } - async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { + async getTopCustomersByBookings( + limit: number, + ): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select(`COALESCE(company.name, 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select(`COALESCE(company.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('company.name') - .orderBy('count', 'DESC') + .groupBy("company.name") + .orderBy("count", "DESC") .limit(limit) .getRawMany<{ label: string; count: string }>(); @@ -502,11 +540,11 @@ export class OverviewRepository { async getUsersByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.userRepository - .createQueryBuilder('user') - .select('user.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('user.status') - .orderBy('count', 'DESC') + .createQueryBuilder("user") + .select("user.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("user.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -515,15 +553,19 @@ export class OverviewRepository { })); } - async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + async getEmployeeGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.employeeRepository - .createQueryBuilder('employee') - .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('employee.is_current = true') - .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('employee.created_at::date') - .orderBy('employee.created_at::date', 'ASC') + .createQueryBuilder("employee") + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("employee.is_current = true") + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("employee.created_at::date") + .orderBy("employee.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -538,16 +580,16 @@ export class OverviewRepository { where: { isActive: true, status: EUserStatus.ACCEPTED }, }), this.userRepository - .createQueryBuilder('user') - .where('user.is_active = false OR user.status != :status', { + .createQueryBuilder("user") + .where("user.is_active = false OR user.status != :status", { status: EUserStatus.ACCEPTED, }) .getCount(), ]); return [ - { label: 'Active', count: active }, - { label: 'Inactive', count: inactive }, + { label: "Active", count: active }, + { label: "Inactive", count: inactive }, ]; } } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 9c92a036d..bcfa643b6 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -18,7 +18,8 @@ import { export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( - process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" + // process.env.PAYMENT_API_URL ?? + "https://paymentcallback.triaplc.com" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index b24febf91..177b7475b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -145,9 +145,7 @@ export class PaymentService { .findOneBy({ id: dto.bookingId }); if (!booking) throw new NotFoundException("Booking not found"); - console.log("bookingbooking",booking) - const amountMinor = Math.round(Number(booking.totalAmount) * 100); - console.log("amountminor",amountMinor) + const amountMinor = Math.round(Number(booking.totalAmount)); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, @@ -159,8 +157,8 @@ export class PaymentService { provider: dto.method as unknown as ProviderMethod, platform: dto.platform, payerAccount: dto.payerAccount, - returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL, - failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL, + returnUrl:'https://edrfreight.triaplc.com/payment/success', + failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7191a203e..dd20b100d 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -49,8 +49,17 @@ export class SchedulingRescheduleService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // A train can be rescheduled (with or without bookings) at any time UNLESS it + // is already on the move (DISPATCHED), has completed its run (ARRIVED), or was + // cancelled. Only DRAFT / SCHEDULED trains are reschedulable. if (schedule.status === TrainScheduleStatus.Dispatched) { - throw new BadRequestException('Cannot reschedule a dispatched train'); + throw new BadRequestException('Cannot reschedule a train that is already dispatched'); + } + if (schedule.status === TrainScheduleStatus.Arrived) { + throw new BadRequestException('Cannot reschedule a train that has already arrived'); + } + if (schedule.status === TrainScheduleStatus.Cancelled) { + throw new BadRequestException('Cannot reschedule a cancelled train'); } const currentOnSchedule = (schedule.scheduleBookings ?? []) @@ -156,10 +165,24 @@ export class SchedulingRescheduleService { } } - const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { - bookingIds: dto.finalBookingIds, - forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', - }); + // A train can be rescheduled even with no bookings (e.g. moved for + // maintenance). assignBookingsToSchedule requires at least one booking, so + // only call it when something is actually being (re)assigned — the new + // departure date above is the meaningful change for an empty train. The + // empty-train branch returns the same schedule-detail shape as the assign + // path so callers get a consistent response. + const assignResult = dto.finalBookingIds.length + ? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { + bookingIds: dto.finalBookingIds, + forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', + }) + : { + ...(await this.trainSchedulingService.getContainerTrainScheduleById( + scheduleId, + )), + warnings: [] as string[], + deferredBookings: [] as unknown[], + }; await this.schedulingRescheduleRepository.createEvent({ trainScheduleId: scheduleId, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 38ab407bc..7316e1610 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts { }; } +/** + * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day + * key for day-level booking pools — it must match the day the portal calendar + * renders, so always derive day keys through this (never `toISOString().slice`). + */ +export function eatDay(date: Date): string { + const { year, month, day } = eatParts(date); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + /** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */ function eatToUtc( year: number, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a08dd71ec..5cd06091a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => { let bookingsRepository: { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; + findBatchPoolByRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => { }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; + getBookableSchedules: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; }; + let notifier: { + payNow: jest.Mock; + secured: jest.Mock; + expired: jest.Mock; + unplaced: jest.Mock; + }; beforeEach(() => { bookingsRepository = { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), + findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => { issues: [], violations: [], }), + getBookableSchedules: jest.fn().mockResolvedValue([]), }; const bookingRepo = { findOne: jest.fn().mockResolvedValue(paidBooking), update: jest.fn().mockResolvedValue(undefined), + // WagonType.find() / global-rules find() fall back to defaults when empty. + find: jest.fn().mockResolvedValue([]), }; dataSource = { getRepository: jest.fn().mockReturnValue(bookingRepo), @@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => { }), }; + notifier = { + payNow: jest.fn(), + secured: jest.fn(), + expired: jest.fn(), + unplaced: jest.fn(), + }; + service = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, - { payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never, + notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, ); @@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => { expect(fillOrder).toBeLessThan(reconcileOrder); expect(reconcileOrder).toBeLessThan(wagonOrder); }); + + describe('fillRouteDay — day-level distribution', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day. + const trainA = 'train-a'; + const trainB = 'train-b'; + + // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits. + const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 }; + + const commercial = (id: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + }) as unknown as Booking; + + beforeEach(() => { + // Two OPEN trains on the same route + day, train A earlier than train B. + trainSchedulingService.getBookableSchedules.mockResolvedValue([ + { + id: trainA, + scheduleDate: '2026-06-20T06:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + { + id: trainB, + scheduleDate: '2026-06-20T09:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => + Promise.resolve({ + id, + maxWagons: 1, + bookingWindowStatus: 'OPEN', + trainSetId: `set-${id}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + }), + ); + }); + + it('spills overflow to the next train by priority, then reports unplaced', async () => { + // 3 commercial bookings, descending priority; only 1 fits per train (2 total). + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + commercial('hi', 30), + commercial('mid', 20), + commercial('lo', 10), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( + originYardId, + destinationYardId, + day, + ); + // Both trains were processed. + expect(touched).toEqual([trainA, trainB]); + // Highest priority reserved on train A, next on train B (commercial → reserve). + const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedOn).toEqual(['hi', 'mid']); + // The third booking fits no train and is reported unplaced (and only it). + expect(notifier.unplaced).toHaveBeenCalledTimes(1); + expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo'); + expect(notifier.unplaced.mock.calls[0][1]).toBe(day); + }); + + it('reserves the chosen train id on each commercial booking', async () => { + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // reserve() persists trainScheduleId so the settle lifecycle can find the train. + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ + trainScheduleId: trainA, + status: 'SELECTED_FOR_BATCH', + }), + ); + }); + }); }); 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 771422fa2..514f8572d 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 @@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { groupBookingsIntoBoardWindows } from './batch-window.util'; +import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { BATCH_CRON, BATCH_TIMEZONE, @@ -42,6 +42,14 @@ interface Capacity { lengthMeters: number; } +/** A day-level pool key: all trains on this route departing on this EAT day. */ +interface RouteDayGroup { + originYardId: string; + destinationYardId: string; + /** EAT calendar day, `yyyy-MM-dd`. */ + day: string; +} + type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = @@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit { private readonly trainSchedulingService: TrainSchedulingService, ) {} - /** On boot, reconcile OPEN schedules and re-arm settle timers. */ + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); - for (const s of open) { + const groups = await this.openRouteDayGroups(); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`); + this.logger.warn( + `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } const reserved = await this.dataSource @@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit { for (const { scheduleId } of reserved) this.armSettle(scheduleId); } - /** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */ + /** + * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to + * (contract sign, payment). Day-level pooling distributes across all of that + * day's trains, so a single schedule id maps to its whole route-day group. + */ enqueueScheduleProcessing(scheduleId: string): void { - void this.processSchedule(scheduleId).catch((err) => - this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`), + void this.processRouteDayForSchedule(scheduleId).catch((err) => + this.logger.error( + `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`, + ), ); } + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ + private async processRouteDayForSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return; + await this.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + } + + /** + * Day-level pipeline: distribute the (route, day) pool across all its trains, + * then settle / reconcile / assign wagons per schedule (those steps stay + * schedule-scoped — only the fill is day-level). + */ + async processRouteDay(group: RouteDayGroup): Promise { + const scheduleIds = await this.fillRouteDay( + group.originYardId, + group.destinationYardId, + group.day, + ); + for (const scheduleId of scheduleIds) { + await this.settleDueReservations(scheduleId); + await this.reconcilePaidUnlinked(scheduleId); + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + } + /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */ async processSchedule(scheduleId: string): Promise { await this.fillSchedule(scheduleId); @@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + private async openRouteDayGroups(): Promise { + const open = await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: 'OPEN' }, + }); + const groups = new Map(); + for (const s of open) { + if (!s.scheduledDepartureDate) continue; + const day = eatDay(s.scheduledDepartureDate); + const key = `${s.originStationId}|${s.destinationStationId}|${day}`; + if (!groups.has(key)) { + groups.set(key, { + originYardId: s.originStationId, + destinationYardId: s.destinationStationId, + day, + }); + } + } + return [...groups.values()]; + } + + private groupLabel(group: RouteDayGroup): string { + return `${group.originYardId}→${group.destinationYardId} on ${group.day}`; + } + /** * Idempotent: link a paid batch booking to its schedule and assign wagons. * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. @@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit { @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); - this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`); - for (const s of open) { + const groups = await this.openRouteDayGroups(); + this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`); + this.logger.error( + `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } } @@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.isGovernment) { await this.allocate(scheduleId, booking, 'gov'); } else { - await this.reserve(booking); + await this.reserve(booking, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } + /** + * Distribute one (route, day) pool across ALL of that day's OPEN trains, by + * priority, filling each train (earliest departure first) until it's full and + * spilling overflow to the next. Government bookings that fit no train preempt + * lower-priority commercial; bookings that fit no train at all stay pending and + * trigger a staff `unplaced` warning. Returns the schedule ids that were touched + * (or that had remaining pool work) so the caller can settle them per-schedule. + */ + async fillRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + // The day's OPEN bookable schedules on this exact corridor, earliest first. + const bookable = await this.trainSchedulingService.getBookableSchedules( + originYardId, + destinationYardId, + ); + const scheduleIds = bookable + .filter( + (s) => + s.bookingWindowStatus === 'OPEN' && + s.scheduleDate != null && + eatDay(new Date(s.scheduleDate)) === day, + ) + .sort( + (a, b) => + new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + ) + .map((s) => s.id); + + if (scheduleIds.length === 0) return []; + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + + // Live per-schedule budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + for (const id of scheduleIds) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + continue; + } + const limits = await this.capacityLimits(locomotive, rules); + await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + trains.push({ id, budget, armed: false }); + } + if (trains.length === 0) return []; + + const pool = await this.bookingsRepository.findBatchPoolByRouteDay( + originYardId, + destinationYardId, + day, + ); + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + // First train (earliest departure) that fits this booking as-is. + let target = trains.find((t) => this.fits(need, t.budget)); + + if (!target && booking.isGovernment) { + // Government booking fits nowhere on its own — try to preempt commercial + // on each train (earliest first) until one frees enough room. + for (const t of trains) { + t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + if (this.fits(need, t.budget)) { + target = t; + break; + } + } + } + + if (!target) { + // Fits no train this day — stays in the pool, retried next batch. + this.notifier.unplaced(booking, day); + continue; + } + + if (booking.isGovernment) { + await this.allocate(target.id, booking, 'gov'); + } else { + await this.reserve(booking, target.id); + target.armed = true; + } + target.budget = this.subtract(target.budget, need); + } + + for (const t of trains) { + if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.armed) this.armSettle(t.id); + void this.triggerWagonAllocation(t.id); + } + + return trains.map((t) => t.id); + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); @@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit { // ---- mutations ------------------------------------------------------------ - /** Reserve capacity for a commercial booking and open its pay window. */ - private async reserve(booking: Booking): Promise { + /** + * Reserve capacity for a commercial booking on a specific train and open its + * pay window. `scheduleId` is persisted so the settle/allocate lifecycle + * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), + * which is all keyed off `booking.trainScheduleId`, can find the train — with + * day-level pooling the booking arrives here with `trainScheduleId` still null, + * so the engine sets it as it picks the train. + */ + private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, status: 'SELECTED_FOR_BATCH', selectedForBatchAt: now, paymentDeadline: deadline, } as never); + booking.trainScheduleId = scheduleId; await this.notifier.payNow(booking, deadline); } @@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } - /** Expire an unpaid reservation and free its capacity. */ + /** + * Expire an unpaid reservation and free its capacity. With day-level pooling we + * also clear `trainScheduleId` so the booking is no longer pinned to the train + * it failed to pay for — it's back in the day pool for staff to act on. + */ private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, status: 'EXPIRED', schedulingStatus: 'ELIGIBLE', paymentDeadline: null, selectedForBatchAt: null, } as never); + booking.trainScheduleId = null; this.notifier.expired(booking); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e63bc1aec..e8f272123 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -67,6 +67,17 @@ export class BookingNotifierService { ); } + /** + * Staff-facing warning when a pooled booking fits no train on its chosen day. + * It stays pending and is retried next batch; staff can add capacity or pin it + * to a train manually. Mirrors {@link scheduleFull} — no customer notification. + */ + unplaced(b: Booking, day: string): void { + this.logger.warn( + `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`, + ); + } + displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts new file mode 100644 index 000000000..bb55508e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class AvailableDaysQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 2a4c3a357..0c01dd219 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; +import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; @@ -108,6 +109,20 @@ export class TrainSchedulingController { ); } + @Get("available-days") + // No staff guard: customers hit this while creating a booking to find which + // DAYS have a departure on their route. Day-level pooling — no capacity is + // returned, only the list of bookable days. + @ApiOperation({ + summary: "Distinct days with an OPEN same-route departure (day-level pool)", + }) + getAvailableDays(@Query() query: AvailableDaysQueryDto) { + return this.trainSchedulingService.getAvailableDays( + query.originYardId, + query.destinationYardId, + ); + } + @Get("container/eligible-bookings") @TrainSchedulingView() @ApiOperation({ summary: "List eligible container bookings" }) 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 17184bf97..7bfe5811e 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 @@ -87,6 +87,7 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -167,12 +168,28 @@ export class TrainSchedulingService { ) {} async getEligibleBookings(query: GetEligibleBookingsDto) { + // Day-level pooling: when the wizard targets a schedule, surface the whole + // (route, EAT day) pool — not just bookings pre-pinned to that train — by + // resolving the schedule's route + day and filtering on the day instead. + let day: string | undefined; + let originStationId = query.originStationId; + let destinationStationId = query.destinationStationId; + if (query.trainScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); + if (schedule?.scheduledDepartureDate) { + day = eatDay(schedule.scheduledDepartureDate); + originStationId = originStationId ?? schedule.originStationId; + destinationStationId = destinationStationId ?? schedule.destinationStationId; + } + } + const bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, - originStationId: query.originStationId, - destinationStationId: query.destinationStationId, + originStationId, + destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, + day, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } @@ -721,27 +738,37 @@ export class TrainSchedulingService { : null; if (route) { - const origin = route.originYard; - const destination = route.destinationYard; + // `route.milestones` is the complete ordered corridor and already includes + // the origin (first) and destination (last) yards — `route.originYardId` + // and `route.destinationYardId` are derived from them. Use the milestones + // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire). const milestones = [...(route.milestones ?? [])].sort( (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, ); + + if (milestones.length > 0) { + milestones.forEach((m, i) => + stations.push({ + sequenceNo: i, + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, + code: m.yard?.code ?? '', + }), + ); + return stations; + } + + // Route with no milestones recorded — fall back to its origin/destination. + const origin = route.originYard; + const destination = route.destinationYard; stations.push({ sequenceNo: 0, yardId: route.originYardId, label: origin?.label ?? origin?.code ?? 'Origin', code: origin?.code ?? '', }); - milestones.forEach((m, i) => - stations.push({ - sequenceNo: i + 1, - yardId: m.yardId, - label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, - code: m.yard?.code ?? '', - }), - ); stations.push({ - sequenceNo: milestones.length + 1, + sequenceNo: 1, yardId: route.destinationYardId, label: destination?.label ?? destination?.code ?? 'Destination', code: destination?.code ?? '', @@ -775,8 +802,16 @@ export class TrainSchedulingService { const stations = await this.buildScheduleStations(schedule); const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + + // Resolve each checkpoint's position by its yard against the canonical + // corridor rather than the stored sequenceNo, so legacy checkpoints logged + // under an older station numbering still line up with the current stations. + const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo])); + const resolvedSeq = (e: TrainCheckpointEvent) => + seqByYard.get(e.yardId) ?? e.sequenceNo; + const currentSequenceNo = events.length - ? Math.max(...events.map((e) => e.sequenceNo)) + ? Math.max(...events.map(resolvedSeq)) : -1; return { @@ -790,13 +825,19 @@ export class TrainSchedulingService { actualArrivalAt: schedule.actualArrivalAt ? schedule.actualArrivalAt.toISOString() : null, + scheduledDepartureAt: schedule.scheduledDepartureDate + ? schedule.scheduledDepartureDate.toISOString() + : null, + scheduledArrivalAt: schedule.scheduledArrivalDate + ? schedule.scheduledArrivalDate.toISOString() + : null, origin: stations[0]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null, stations, currentSequenceNo, checkpoints: events.map((e) => ({ id: e.id, - sequenceNo: e.sequenceNo, + sequenceNo: resolvedSeq(e), yardId: e.yardId, label: e.yard?.label ?? e.yard?.code ?? null, kind: e.kind, @@ -1965,6 +2006,33 @@ export class TrainSchedulingService { return filteredSchedules; } + /** + * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable + * departure on the route. Customers pick a DAY (not a train) — so this returns + * only the day strings, no capacity, counts or train info. + */ + async getAvailableDays( + originYardId?: string, + destinationYardId?: string, + ): Promise<{ days: string[] }> { + const schedules = await this.getBookableSchedules(originYardId, destinationYardId); + const days = new Set(); + for (const s of schedules) { + if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate))); + } + return { days: [...days].sort() }; + } + + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ + async existsOpenScheduleOnRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + return days.includes(day); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index 455eb405d..a220218e0 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -29,6 +29,13 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) + physicalWagonId!: string | null; + + @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'physical_wagon_id' }) + physicalWagon?: Wagon | null; + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) @JoinColumn({ name: 'wagon_type_id' }) wagonType?: WagonType; @@ -45,13 +52,6 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) assignedWeightTons!: number; - @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) - physicalWagonId?: string | null; - - @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'physical_wagon_id' }) - physicalWagon?: Wagon | null; - @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) status!: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts new file mode 100644 index 000000000..fb5c4e92b --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; + +export class CreateVehicleDto { + @IsString() + plateNumber!: string; + + @IsEnum(VehicleType) + vehicleType!: VehicleType; + + @IsString() + manufacturer!: string; + + @IsString() + model!: string; + + @IsNumber() + year!: number; + + @IsEnum(FuelType) + fuelType!: FuelType; + + @IsNumber() + capacity!: number; + + @IsEnum(VehicleStatus) + status!: VehicleStatus; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts new file mode 100644 index 000000000..953917b2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVehicleDto } from './create-vehicle.dto'; + +export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts new file mode 100644 index 000000000..773e8051a --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -0,0 +1,64 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +export enum VehicleType { + TRUCK = 'TRUCK', + VAN = 'VAN', + CAR = 'CAR', + BUS = 'BUS', + TRAILER = 'TRAILER', + TANKER = 'TANKER', + FLATBED = 'FLATBED', +} + +export enum FuelType { + PETROL = 'PETROL', + DIESEL = 'DIESEL', + ELECTRIC = 'ELECTRIC', + HYBRID = 'HYBRID', +} + +export enum VehicleStatus { + ACTIVE = 'ACTIVE', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', + OUT_OF_SERVICE = 'OUT_OF_SERVICE', +} + +@Entity({ name: 'vehicles', schema: 'freight' }) +@Index(['plateNumber']) +@Index(['registrationNumber']) +@Index(['status']) +@Index(['vehicleType']) +@Index(['manufacturer']) +export class Vehicle extends BaseEntity { + @Column({ name: 'plate_number', unique: true }) + plateNumber!: string; + + @Column({ name: 'registration_number', unique: true }) + registrationNumber!: string; + + @Column({ name: 'vehicle_type', type: 'varchar' }) + vehicleType!: VehicleType; + + @Column() + manufacturer!: string; + + @Column() + model!: string; + + @Column() + year!: number; + + @Column({ name: 'fuel_type', type: 'varchar' }) + fuelType!: FuelType; + + @Column() + capacity!: number; + + @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE }) + status!: VehicleStatus; + + @Column({ type: 'text', nullable: true }) + description!: string | null; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts new file mode 100644 index 000000000..24ff2d022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { VehiclesService } from './vehicles.service'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; + +@ApiTags('vehicles') +@ApiBearerAuth() +@Controller('vehicles') +@FleetView() +export class VehiclesController { + constructor(private readonly vehiclesService: VehiclesService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new vehicle' }) + create(@Body() createVehicleDto: CreateVehicleDto) { + return this.vehiclesService.create(createVehicleDto); + } + + @Get() + @ApiOperation({ summary: 'Get all vehicles with filters' }) + findAll( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + return this.vehiclesService.findAll({ + search, + status: status as any, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + sortBy, + sortOrder, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get vehicle by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a vehicle' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateVehicleDto: UpdateVehicleDto, + ) { + return this.vehiclesService.update(id, updateVehicleDto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a vehicle' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts new file mode 100644 index 000000000..07aa4bd2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vehicle } from './entities/vehicle.entity'; +import { VehiclesService } from './vehicles.service'; +import { VehiclesController } from './vehicles.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vehicle])], + providers: [VehiclesService], + controllers: [VehiclesController], + exports: [VehiclesService], +}) +export class VehiclesModule {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts new file mode 100644 index 000000000..9c5bad1e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Vehicle } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesRepository extends BaseRepository { + constructor( + @InjectRepository(Vehicle) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts new file mode 100644 index 000000000..12970a8a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesService { + constructor( + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + ) {} + + async create(dto: CreateVehicleDto): Promise { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const vehicle = this.vehicleRepo.create({ + ...dto, + registrationNumber, + }); + + return this.vehicleRepo.save(vehicle); + } + + async findAll(query: { + search?: string; + status?: VehicleStatus | string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> { + const page = query.page || 1; + const limit = query.limit || 10; + const skip = (page - 1) * limit; + + const where: any = {}; + if (query.status) where.status = query.status; + + let qb = this.vehicleRepo.createQueryBuilder('v'); + + if (query.search) { + qb = qb.where( + 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + { search: `%${query.search}%` }, + ); + } + + if (query.status) { + qb = qb.andWhere('v.status = :status', { status: query.status }); + } + + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( + query.sortBy ?? '', + ) + ? query.sortBy + : 'createdAt'; + const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase(); + + const [data, total] = await qb + .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(id: string): Promise { + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + if (!vehicle) { + throw new NotFoundException(`Vehicle ${id} not found`); + } + return vehicle; + } + + async update(id: string, dto: UpdateVehicleDto): Promise { + const vehicle = await this.findById(id); + + if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + } + + Object.assign(vehicle, dto); + return this.vehicleRepo.save(vehicle); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.vehicleRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 914de4cbd..bffe28860 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; import { WagonsService } from './wagons.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, Train])], + imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])], controllers: [WagonsController, TrainWagonsReorderController], providers: [WagonsService], exports: [WagonsService], }) -export class WagonsModule {} \ No newline at end of file +export class WagonsModule {} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts new file mode 100644 index 000000000..43cd6f61a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -0,0 +1,96 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateAllocationRuleDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + priority?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + requiresInspection?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetFacilityCode?: string; + + @ApiProperty() + @IsString() + targetYardCode!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetWarehouseCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetZoneCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storageType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateAllocationRuleDto extends PartialType(CreateAllocationRuleDto) {} + +export class AllocationPreviewDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + requiresInspection?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts new file mode 100644 index 000000000..cc1350796 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + INSPECTION_REPORT_TYPES, + INSPECTION_STATUSES, + InspectionReportType, + InspectionStatus, +} from '../entities/warehouse-inspection-report.entity'; + +export class CreateInspectionReportDto { + @ApiProperty({ enum: INSPECTION_REPORT_TYPES }) + @IsEnum(INSPECTION_REPORT_TYPES) + reportType!: InspectionReportType; + + @ApiProperty({ enum: INSPECTION_STATUSES }) + @IsEnum(INSPECTION_STATUSES) + inspectionStatus!: InspectionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasDamage?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + damageDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasWeightLoss?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + expectedWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + actualWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasMissingItems?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + missingItemsDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + inspectedById?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts new file mode 100644 index 000000000..ccdda90d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; + +export class CreateWarehouseYardDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_YARD_TYPES }) + @IsEnum(WAREHOUSE_YARD_TYPES) + type!: WarehouseYardType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts new file mode 100644 index 000000000..fbb057fd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity'; + +export class CreateWarehouseZoneDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_ZONE_TYPES }) + @IsEnum(WAREHOUSE_ZONE_TYPES) + type!: WarehouseZoneType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts new file mode 100644 index 000000000..788c798bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; + +export class CreateWarehouseDto { + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_TYPES }) + @IsEnum(WAREHOUSE_TYPES) + type!: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(200) + locationName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} 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 new file mode 100644 index 000000000..873f97a6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; + +export class CreateFeeRuleDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty({ enum: FEE_RULE_TYPES }) + @IsEnum(FEE_RULE_TYPES) + ruleType!: FeeRuleType; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + priority?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerType?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiProperty({ description: 'Grace period in days before charging starts.' }) + @IsInt() + @Min(0) + freeDays!: number; + + @ApiProperty() + @IsNumber() + @Min(0) + ratePerDay!: number; + + @ApiPropertyOptional({ default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; +} + +export class UpdateFeeRuleDto extends PartialType(CreateFeeRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts new file mode 100644 index 000000000..c867eec3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -0,0 +1,54 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class FilterWarehouseInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts new file mode 100644 index 000000000..f07088ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; + +export class FilterWarehouseDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_TYPES }) + @IsOptional() + @IsEnum(WAREHOUSE_TYPES) + type?: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts new file mode 100644 index 000000000..cba259d00 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -0,0 +1,49 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class InquiryWarehouseInventoryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + bookingNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + goodsName?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts new file mode 100644 index 000000000..9d25c974a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class GenerateInvoiceDto { + @ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' }) + @IsOptional() + @IsBoolean() + confirmZero?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class PayInvoiceBodyDto { + @ApiPropertyOptional() + @IsNumber() + @Min(0.01) + amount!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts new file mode 100644 index 000000000..063bb7d1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class LoadInventoryDto { + @ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' }) + @IsUUID() + wagonId!: string; + + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @IsOptional() + @IsNumber() + @Min(0) + loadedWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(120) + loadedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts new file mode 100644 index 000000000..1aae7896f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class MoveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + movedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts new file mode 100644 index 000000000..46fecb044 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +export class ReceiveWarehouseInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + quantity!: number; + + @ApiProperty() + @IsNumber() + @Min(0) + weight!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts new file mode 100644 index 000000000..f1cb05d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class ReserveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + inventoryId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts new file mode 100644 index 000000000..3138285e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UnloadBookingDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + unloadedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts new file mode 100644 index 000000000..372008d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './create-inspection-report.dto'; + +export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts new file mode 100644 index 000000000..717923534 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity'; +import { CreateWarehouseYardDto } from './create-warehouse-yard.dto'; + +export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_STATUSES) + status?: WarehouseYardStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts new file mode 100644 index 000000000..01cd8301d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity'; +import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto'; + +export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_ZONE_STATUSES) + status?: WarehouseZoneStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts new file mode 100644 index 000000000..e6038fca2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity'; +import { CreateWarehouseDto } from './create-warehouse.dto'; + +export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts new file mode 100644 index 000000000..4521bb808 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_ACTIVITY_TYPES = [ + 'INVENTORY_RECEIVED', + 'INVENTORY_STORED', + 'INVENTORY_MOVED', + 'INVENTORY_RESERVED', + 'READY_FOR_LOADING', + 'INVENTORY_LOADED', + 'INVENTORY_DISPATCHED', +] as const; +export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_activity_log' }) +@Index(['inventoryId']) +@Index(['warehouseId']) +@Index(['activityType']) +export class WarehouseActivityLog extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'activity_type', type: 'varchar', length: 40 }) + activityType!: WarehouseActivityType; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts new file mode 100644 index 000000000..a597ecf0e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Batch 5 — deterministic warehouse/yard allocation. + * A booking's (freightType, tradeDirection, cargoType, containerStatus, inspection) + * is matched against active rules in ascending `priority`; the first match wins and + * resolves the target Yard (and optional Warehouse/Zone) by code. + */ +@Entity({ schema: 'freight', name: 'warehouse_allocation_rules' }) +@Index(['priority']) +@Index(['isActive']) +export class WarehouseAllocationRule extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'priority', type: 'int', default: 100 }) + priority!: number; + + // ── Match criteria (null = wildcard) ────────────────────────────────────── + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: string | null; // CONTAINER | BULK + + @Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true }) + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + + @Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true }) + cargoTypeCode?: string | null; + + @Column({ name: 'container_status', type: 'varchar', length: 24, nullable: true }) + containerStatus?: string | null; // e.g. EMPTY | MAINTENANCE + + @Column({ name: 'requires_inspection', type: 'boolean', nullable: true }) + requiresInspection?: boolean | null; + + // ── Resolved target (by code) ───────────────────────────────────────────── + @Column({ name: 'target_facility_code', type: 'varchar', length: 40, nullable: true }) + targetFacilityCode?: string | null; + + @Column({ name: 'target_yard_code', type: 'varchar', length: 40 }) + targetYardCode!: string; + + @Column({ name: 'target_warehouse_code', type: 'varchar', length: 40, nullable: true }) + targetWarehouseCode?: string | null; + + @Column({ name: 'target_zone_code', type: 'varchar', length: 40, nullable: true }) + targetZoneCode?: string | null; + + @Column({ name: 'storage_type', type: 'varchar', length: 80, nullable: true }) + storageType?: string | null; // descriptive: "Container terminal import / stack area" + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts new file mode 100644 index 000000000..8b14dcea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) +@Index(['invoiceId']) +export class WarehouseFeeInvoiceItem extends BaseEntity { + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId!: string; + + @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice?: WarehouseFeeInvoice; + + @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) + feeRuleId?: string | null; + + @Column({ name: 'fee_type', type: 'varchar', length: 32 }) + feeType!: WarehouseFeeType; + + @Column({ name: 'description', type: 'varchar', length: 255 }) + description!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) + quantity!: number; + + @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + amount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + @Column({ name: 'chargeable_days', type: 'int', nullable: true }) + chargeableDays?: number | null; + + @Column({ name: 'free_days', type: 'int', nullable: true }) + freeDays?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts new file mode 100644 index 000000000..e57d626d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts @@ -0,0 +1,107 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** + * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. + * Owns warehouse fees; links to booking/customer/inventory/location so it can + * connect to the existing payment module without duplicating it. + */ +@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) +@Index(['invoiceNumber'], { unique: true }) +@Index(['bookingId']) +@Index(['inventoryId']) +@Index(['status']) +export class WarehouseFeeInvoice extends BaseEntity { + @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) + invoiceNumber!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId?: string | null; + + @Column({ name: 'zone_id', type: 'uuid', nullable: true }) + zoneId?: string | null; + + @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) + invoiceType!: WarehouseInvoiceType; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: WarehouseInvoiceStatus; + + @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + + @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + totalAmount!: number; + + @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ + @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) + periodStart?: Date | null; + + @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) + periodEnd?: Date | null; + + @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) + issuedAt?: Date | null; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate?: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) + payments!: WarehouseInvoicePayment[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} 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 new file mode 100644 index 000000000..f346be282 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +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]; + +/** + * 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. + * `freeDays` is the grace period; charging starts the day after it expires. + */ +@Entity({ schema: 'freight', name: 'warehouse_fee_rules' }) +@Index(['ruleType']) +@Index(['isActive']) +export class WarehouseFeeRule extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'rule_type', type: 'varchar', length: 20 }) + ruleType!: FeeRuleType; + + @Column({ name: 'priority', type: 'int', default: 100 }) + priority!: number; + + // ── Scope (null = applies to all) ───────────────────────────────────────── + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: string | null; // CONTAINER | BULK + + @Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true }) + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + + @Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true }) + cargoTypeCode?: string | null; + + @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) + containerType?: string | null; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId?: string | null; + + @Column({ name: 'zone_id', type: 'uuid', nullable: true }) + zoneId?: string | null; + + // ── Fee definition ──────────────────────────────────────────────────────── + @Column({ name: 'free_days', type: 'int', default: 0 }) + freeDays!: number; + + @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) + ratePerDay!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts new file mode 100644 index 000000000..598b8d168 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts @@ -0,0 +1,77 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +export const INSPECTION_REPORT_TYPES = [ + 'INSPECTION', + 'DAMAGE', + 'WEIGHT_LOSS', + 'MISSING_ITEM', + 'GENERAL', +] as const; +export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number]; + +export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const; +export type InspectionStatus = (typeof INSPECTION_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_inspection_reports' }) +@Index(['inventoryId']) +@Index(['bookingId']) +@Index(['inspectionStatus']) +export class WarehouseInspectionReport extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'report_type', type: 'varchar', length: 32, default: 'INSPECTION' }) + reportType!: InspectionReportType; + + @Column({ name: 'inspection_status', type: 'varchar', length: 20, default: 'NEEDS_REVIEW' }) + inspectionStatus!: InspectionStatus; + + @Column({ name: 'has_damage', type: 'boolean', default: false }) + hasDamage!: boolean; + + @Column({ name: 'damage_description', type: 'text', nullable: true }) + damageDescription?: string | null; + + @Column({ name: 'has_weight_loss', type: 'boolean', default: false }) + hasWeightLoss!: boolean; + + @Column({ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + expectedWeight?: number | null; + + @Column({ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + actualWeight?: number | null; + + @Column({ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightLoss?: number | null; + + @Column({ name: 'weight_loss_unit', type: 'varchar', length: 12, nullable: true }) + weightLossUnit?: string | null; + + @Column({ name: 'has_missing_items', type: 'boolean', default: false }) + hasMissingItems!: boolean; + + @Column({ name: 'missing_items_description', type: 'text', nullable: true }) + missingItemsDescription?: string | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'inspected_by_id', type: 'uuid', nullable: true }) + inspectedById?: string | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts new file mode 100644 index 000000000..4ecfdc0dd --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' }) +@Index(['inventoryId']) +export class WarehouseInventoryMovement extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'from_warehouse_id', type: 'uuid' }) + fromWarehouseId!: string; + + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @Column({ name: 'from_zone_id', type: 'uuid' }) + fromZoneId!: string; + + @Column({ name: 'to_warehouse_id', type: 'uuid' }) + toWarehouseId!: string; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @Column({ name: 'to_zone_id', type: 'uuid' }) + toZoneId!: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true }) + movedBy?: string | null; + + @Column({ name: 'moved_at', type: 'timestamptz' }) + movedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts new file mode 100644 index 000000000..6c9270987 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -0,0 +1,143 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { Warehouse } from './warehouse.entity'; +import { WarehouseYard } from './warehouse-yard.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +// Batch 2 lifecycle. Supersedes the Batch 1 set +// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. +export const WAREHOUSE_INVENTORY_STATUSES = [ + 'RECEIVED', + 'STORED', + 'RESERVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DISPATCHED', +] as const; +export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; + +/** Allowed forward transitions for the inventory lifecycle. */ +export const WAREHOUSE_INVENTORY_TRANSITIONS: Record = { + RECEIVED: ['STORED'], + STORED: ['RESERVED'], + RESERVED: ['READY_FOR_LOADING'], + READY_FOR_LOADING: ['LOADED'], + LOADED: ['DISPATCHED'], + DISPATCHED: [], +}; + +@Entity({ schema: 'freight', name: 'warehouse_inventory' }) +@Index(['warehouseId']) +@Index(['yardId']) +@Index(['zoneId']) +@Index(['bookingId']) +@Index(['cargoId']) +@Index(['containerId']) +@Index(['goodsId']) +@Index(['status']) +export class WarehouseInventory extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'zone_id', type: 'uuid' }) + zoneId!: string; + + @ManyToOne(() => WarehouseZone) + @JoinColumn({ name: 'zone_id' }) + zone?: WarehouseZone; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @ManyToOne(() => Cargo, { nullable: true }) + @JoinColumn({ name: 'cargo_id' }) + cargo?: Cargo | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @ManyToOne(() => Container, { nullable: true }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + + @Column({ name: 'goods_id', type: 'uuid', nullable: true }) + goodsId?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + weight!: number; + + @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) + volume?: number | null; + + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) + status!: WarehouseInventoryStatus; + + // Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected. + @Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true }) + inspectionStatus?: string | null; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) + storedAt?: Date | null; + + @Column({ name: 'reserved_at', type: 'timestamptz', nullable: true }) + reservedAt?: Date | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true }) + dispatchedAt?: Date | null; + + // Batch 5 — demurrage / storage lifecycle timestamps. + @Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true }) + inspectionStartedAt?: Date | null; + + @Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true }) + inspectionCompletedAt?: Date | null; + + @Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true }) + readyForPickupAt?: Date | null; + + @Column({ name: 'release_date', type: 'timestamptz', nullable: true }) + releaseDate?: Date | null; + + @Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true }) + gateClearedAt?: Date | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts new file mode 100644 index 000000000..5f6952aec --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { WarehouseInventory } from './warehouse-inventory.entity'; + +/** + * Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon. + * The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling + * domain (via SchedulingReadFacade); it never writes to wagons or train schedules. + */ +@Entity({ schema: 'freight', name: 'warehouse_loadings' }) +@Index(['warehouseInventoryId']) +@Index(['bookingId']) +@Index(['wagonId']) +export class WarehouseLoading extends BaseEntity { + @Column({ name: 'warehouse_inventory_id', type: 'uuid' }) + warehouseInventoryId!: string; + + @ManyToOne(() => WarehouseInventory) + @JoinColumn({ name: 'warehouse_inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @Column({ name: 'loaded_at', type: 'timestamptz' }) + loadedAt!: Date; + + @Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true }) + loadedBy?: string | null; + + @Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + loadedWeight?: number | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts new file mode 100644 index 000000000..6e3c93292 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Warehouse } from './warehouse.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +export const WAREHOUSE_YARD_TYPES = [ + 'CONTAINER_YARD', + 'BULK_YARD', + 'GENERAL_CARGO_YARD', + 'HAZARDOUS_YARD', + 'COLD_STORAGE_YARD', +] as const; +export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; + +export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_yards' }) +@Index(['warehouseId']) +@Index(['type']) +@Index(['status']) +export class WarehouseYard extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseYardType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseYardStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseZone, (zone) => zone.yard) + zones?: WarehouseZone[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 000000000..9cfaad6de --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_ZONE_TYPES = [ + 'CONTAINER_ZONE', + 'BULK_ZONE', + 'GENERAL_CARGO_ZONE', + 'HAZARDOUS_ZONE', + 'COLD_STORAGE_ZONE', +] as const; +export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number]; + +export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_zones' }) +@Index(['yardId']) +@Index(['type']) +@Index(['status']) +export class WarehouseZone extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseZoneType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseZoneStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 000000000..9d27ed64a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,70 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; + +import { Facility } from '../../facilities/entities/facility.entity'; +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; +export type WarehouseType = (typeof WAREHOUSE_TYPES)[number]; + +export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouses' }) +@Index(['code'], { unique: true }) +@Index(['type']) +@Index(['status']) +@Index(['stationId']) +export class Warehouse extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseType; + + @Column({ name: 'station_id', type: 'uuid', nullable: true }) + stationId?: string | null; + + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) + locationName?: string | null; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + // Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight. + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true }) + facility?: Facility | null; + + @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) + yards?: WarehouseYard[]; +} 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 new file mode 100644 index 000000000..de5a791c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. + * + * IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify + * wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow. + * It is intentionally decoupled (raw SQL) so it does not import the scheduling + * services/entities and cannot accidentally write to them. + */ +export interface WagonView { + id: string; + wagonNumber: string; + status: string; + trainId: string | null; +} + +export interface BookingScheduleView { + schedule: { + id: string; + status: string; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + originStationId: string | null; + destinationStationId: string | null; + } | null; + wagon: { + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; + } | null; + /** Mirror of schedule.status — the headline "where is the train" indicator. */ + departureStatus: string | null; +} + +@Injectable() +export class SchedulingReadFacade { + constructor(private readonly dataSource: DataSource) {} + + /** Look up a single physical wagon. Returns null if it does not exist. */ + async findWagon(wagonId: string): Promise { + const rows = await this.dataSource.query( + `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" + FROM freight.wagons + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return rows?.[0] ?? null; + } + + /** True when the wagon is already part of a train set (selected by an existing schedule). */ + async isWagonScheduled(wagonId: string): Promise { + const rows = await this.dataSource.query( + `SELECT 1 FROM freight.train_set_wagons + WHERE physical_wagon_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return (rows?.length ?? 0) > 0; + } + + /** List wagons usable for loading (available, or already assigned to a schedule). */ + listLoadableWagons(): Promise { + return this.dataSource.query( + `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" + FROM freight.wagons + WHERE deleted_at IS NULL + AND status NOT IN ('RETIRED', 'MAINTENANCE') + ORDER BY wagon_number ASC`, + ); + } + + /** + * Given a booking, return its related schedule, wagon assignment and departure status. + * All fields are read straight from the scheduling tables — nothing is written. + */ + async getBookingSchedule(bookingId: string): Promise { + const scheduleRows = await this.dataSource.query( + `SELECT ts.id, + ts.status, + ts.scheduled_departure_date AS "scheduledDepartureDate", + ts.scheduled_arrival_date AS "scheduledArrivalDate", + ts.origin_station_id AS "originStationId", + ts.destination_station_id AS "destinationStationId" + FROM freight.train_schedule_bookings tsb + INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL + ORDER BY ts.scheduled_departure_date DESC NULLS LAST + LIMIT 1`, + [bookingId], + ); + const schedule = scheduleRows?.[0] ?? null; + + const wagonRows = await this.dataSource.query( + `SELECT w.id AS "wagonId", + w.wagon_number AS "wagonNumber", + tsw.sequence_no AS "sequenceNo", + wba.allocated_weight_tons AS "allocatedWeightTons" + FROM freight.wagon_booking_allocations wba + INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE wba.booking_id = $1 + ORDER BY tsw.sequence_no ASC NULLS LAST + LIMIT 1`, + [bookingId], + ); + const wagon = wagonRows?.[0] ?? null; + + return { + schedule, + wagon, + departureStatus: schedule?.status ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts new file mode 100644 index 000000000..2e4eb7fae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; + +@Injectable() +export class WarehouseActivityLogRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseActivityLog) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts new file mode 100644 index 000000000..74c823a03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { + WarehouseActivityLog, + WarehouseActivityType, +} from './entities/warehouse-activity-log.entity'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; + +interface LogInput { + activityType: WarehouseActivityType; + description?: string; + inventoryId?: string | null; + warehouseId?: string | null; + performedBy?: string | null; +} + +@Injectable() +export class WarehouseActivityLogService { + constructor(private readonly logRepository: WarehouseActivityLogRepository) {} + + /** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */ + async record(input: LogInput, manager?: EntityManager): Promise { + const data = { + activityType: input.activityType, + description: input.description ?? null, + inventoryId: input.inventoryId ?? null, + warehouseId: input.warehouseId ?? null, + performedBy: input.performedBy ?? 'system', + }; + + if (manager) { + await manager.getRepository(WarehouseActivityLog).save( + manager.getRepository(WarehouseActivityLog).create(data), + ); + return; + } + + await this.logRepository.create(data); + } + + findByInventory(inventoryId: string): Promise { + return this.logRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts new file mode 100644 index 000000000..a066c93f1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; + +@Injectable() +export class WarehouseAllocationRuleRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseAllocationRule) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts new file mode 100644 index 000000000..f110dfcf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { CreateAllocationRuleDto, UpdateAllocationRuleDto } from './dto/allocation-rule.dto'; +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; +import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; + +export interface AllocationCriteria { + freightType?: string | null; // CONTAINER | BULK + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + cargoTypeCode?: string | null; + containerStatus?: string | null; // EMPTY | MAINTENANCE | ... + requiresInspection?: boolean | null; +} + +export interface AllocationResult { + warehouseId: string; + yardId: string; + zoneId: string; + facilityId: string | null; + rule: { id: string; name: string; storageType: string | null } | null; + /** Human-readable path: Facility → Warehouse → Yard → Zone. */ + path: string; +} + +/** + * Batch 5 — deterministic warehouse/yard allocation driven by configurable rules. + * Never assigns randomly: matches criteria against active rules by priority and + * resolves the target Yard/Warehouse/Zone by code. + */ +@Injectable() +export class WarehouseAllocationService { + constructor( + private readonly dataSource: DataSource, + private readonly ruleRepository: WarehouseAllocationRuleRepository, + ) {} + + // ── Rule CRUD ────────────────────────────────────────────────────────────── + listRules(): Promise { + return this.ruleRepository.findAll({ order: { priority: 'ASC' } }); + } + + createRule(dto: CreateAllocationRuleDto): Promise { + return this.ruleRepository.create({ isActive: true, priority: 100, ...dto }); + } + + async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise { + const updated = await this.ruleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + return this.ruleRepository.softDelete(id); + } + + private matches(rule: WarehouseAllocationRule, c: AllocationCriteria): boolean { + const eq = (ruleVal?: string | null, inVal?: string | null) => + ruleVal == null || (inVal != null && ruleVal.toUpperCase() === inVal.toUpperCase()); + return ( + eq(rule.freightType, c.freightType) && + eq(rule.tradeDirection, c.tradeDirection) && + eq(rule.cargoTypeCode, c.cargoTypeCode) && + eq(rule.containerStatus, c.containerStatus) && + (rule.requiresInspection == null || rule.requiresInspection === Boolean(c.requiresInspection)) + ); + } + + /** First active rule (by priority) whose criteria match. */ + async findMatchingRule(criteria: AllocationCriteria): Promise { + const rules = await this.ruleRepository.findAll({ + where: { isActive: true }, + order: { priority: 'ASC' }, + }); + return rules.find((r) => this.matches(r, criteria)) ?? null; + } + + /** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */ + async resolveLocation(criteria: AllocationCriteria): Promise { + const rule = await this.findMatchingRule(criteria); + const yardCode = rule?.targetYardCode; + + // Resolve yard (by rule code, else first available yard with a zone). + const [yard] = await this.dataSource.query( + yardCode + ? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1` + : `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL + WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`, + yardCode ? [yardCode] : [], + ); + if (!yard) return null; + + // Zone: rule code if given, else first zone in the yard. + const [zone] = await this.dataSource.query( + rule?.targetZoneCode + ? `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.code = $1 AND z.deleted_at IS NULL LIMIT 1` + : `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.yard_id = $1 AND z.deleted_at IS NULL ORDER BY z.created_at ASC LIMIT 1`, + rule?.targetZoneCode ? [rule.targetZoneCode] : [yard.id], + ); + if (!zone) return null; + + const [wh] = await this.dataSource.query( + `SELECT w.id, w.name, w.facility_id AS "facilityId", + (SELECT name FROM freight.facilities f WHERE f.id = w.facility_id) AS "facilityName" + FROM freight.warehouses w WHERE w.id = $1 AND w.deleted_at IS NULL LIMIT 1`, + [yard.warehouseId], + ); + + return { + warehouseId: yard.warehouseId, + yardId: yard.id, + zoneId: zone.id, + facilityId: wh?.facilityId ?? null, + rule: rule ? { id: rule.id, name: rule.name, storageType: rule.storageType ?? null } : null, + path: [wh?.facilityName, wh?.name, yard.name, zone.name].filter(Boolean).join(' → '), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts new file mode 100644 index 000000000..1bb5b1289 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +export interface WarehouseDashboard { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +@Injectable() +export class WarehouseDashboardService { + constructor(private readonly dataSource: DataSource) {} + + async getDashboard(): Promise { + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] = + await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'STORED' } }), + inventoryRepo.count({ where: { status: 'RESERVED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), + inventoryRepo.count({ where: { status: 'LOADED' } }), + inventoryRepo.count({ where: { status: 'DISPATCHED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts new file mode 100644 index 000000000..5b5df396e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; + +@Injectable() +export class WarehouseFeeInvoiceItemRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts new file mode 100644 index 000000000..97328f46d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; + +@Injectable() +export class WarehouseFeeInvoiceRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts new file mode 100644 index 000000000..5b5d3b2ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +@Injectable() +export class WarehouseFeeRuleRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeRule) repository: Repository) { + super(repository); + } +} 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 new file mode 100644 index 000000000..ccf66deb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -0,0 +1,168 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; + +interface ItemAttributes { + arrivedAt: Date | null; + gateClearedAt: Date | null; + releaseDate: Date | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + containerTypeCode: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; +} + +export interface FeePreview { + ruleType: FeeRuleType; + ruleId: string | null; + ruleName: string | null; + freeDays: number; + ratePerDay: number; + currency: string; + startDate: string | null; + endDate: string; + endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) + elapsedDays: number; + chargeableDays: number; + amount: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +@Injectable() +export class WarehouseFeeService { + constructor( + private readonly dataSource: DataSource, + private readonly feeRuleRepository: WarehouseFeeRuleRepository, + ) {} + + // ── Rule CRUD ────────────────────────────────────────────────────────────── + listRules(): Promise { + return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); + } + + createRule(dto: CreateFeeRuleDto): Promise { + return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + } + + async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { + const updated = await this.feeRuleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + return this.feeRuleRepository.softDelete(id); + } + + private async loadItem(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.arrived_at AS "arrivedAt", + inv.gate_cleared_at AS "gateClearedAt", + inv.release_date AS "releaseDate", + inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", + inv.zone_id AS "zoneId", + w.facility_id AS "facilityId", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode", + ctt.code AS "containerTypeCode" + 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.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL`, + [inventoryId], + ); + if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + return row; + } + + 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()) { + 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.cargoTypeCode, item.cargoTypeCode)) return null; + if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.facilityId, item.facilityId)) return null; + if (!check(rule.warehouseId, item.warehouseId)) return null; + if (!check(rule.yardId, item.yardId)) return null; + if (!check(rule.zoneId, item.zoneId)) return null; + return score; + } + + private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null { + let best: WarehouseFeeRule | null = null; + let bestScore = -1; + for (const rule of rules) { + const score = this.matchScore(rule, item); + if (score == null) continue; + if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) { + best = rule; + bestScore = score; + } + } + return best; + } + + private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview { + const start = item.arrivedAt ? new Date(item.arrivedAt) : null; + const endDate = item.gateClearedAt ?? item.releaseDate ?? now; + const endIsOpen = !item.gateClearedAt && !item.releaseDate; + const freeDays = rule?.freeDays ?? 0; + const ratePerDay = Number(rule?.ratePerDay ?? 0); + + 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 amount = Math.round(chargeableDays * ratePerDay * 100) / 100; + + return { + ruleType, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays, + ratePerDay, + currency: rule?.currency ?? 'USD', + startDate: start ? start.toISOString() : null, + endDate: new Date(endDate).toISOString(), + endIsOpen, + elapsedDays, + chargeableDays, + amount, + }; + } + + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ + async previewForInventory(inventoryId: string): Promise { + const item = await this.loadItem(inventoryId); + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const now = new Date(); + + const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; + return byType.map((type) => + this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts new file mode 100644 index 000000000..533b0c6ae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; + +@ApiTags('warehouse-inspection') +@ApiBearerAuth() +@Controller() +export class WarehouseInspectionController { + constructor(private readonly inspectionService: WarehouseInspectionService) {} + + @Post('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) + create( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: CreateInspectionReportDto, + ) { + return this.inspectionService.create(inventoryId, dto); + } + + @Get('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'List inspection reports for an inventory item' }) + listByInventory(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.inspectionService.findByInventory(inventoryId); + } + + @Get('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Get an inspection report (with attachments)' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const report = await this.inspectionService.findById(id); + const attachments = await this.inspectionService.listAttachments(id); + return { ...report, attachments }; + } + + @Patch('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Update an inspection report' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { + return this.inspectionService.update(id, dto); + } + + @Post('warehouse-inspection-reports/:id/attachments') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload inspection images / documents' }) + addAttachments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.inspectionService.addAttachments(id, files); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts new file mode 100644 index 000000000..4cd92e846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; + +@Injectable() +export class WarehouseInspectionRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInspectionReport) repository: Repository, + ) { + super(repository); + } +} 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 new file mode 100644 index 000000000..9d1d0f148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -0,0 +1,116 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { FilesService } from '../files/files.service'; +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; + +const INSPECTION_RESOURCE = 'warehouse-inspection-report'; + +@Injectable() +export class WarehouseInspectionService { + constructor( + private readonly dataSource: DataSource, + private readonly inspectionRepository: WarehouseInspectionRepository, + private readonly filesService: FilesService, + ) {} + + /** Create an inspection report for an inventory item and sync its inspectionStatus. */ + async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); + if (!inventory) { + throw new NotFoundException(`Inventory item ${inventoryId} not found`); + } + + const expected = dto.expectedWeight ?? null; + const actual = dto.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; + + const report = await this.inspectionRepository.create({ + inventoryId, + bookingId: inventory.bookingId ?? null, + reportType: dto.reportType, + inspectionStatus: dto.inspectionStatus, + hasDamage: dto.hasDamage ?? false, + damageDescription: dto.damageDescription ?? null, + hasWeightLoss: dto.hasWeightLoss ?? false, + expectedWeight: expected, + actualWeight: actual, + weightLoss, + weightLossUnit: weightLoss !== null ? 'kg' : null, + hasMissingItems: dto.hasMissingItems ?? false, + missingItemsDescription: dto.missingItemsDescription ?? null, + remarks: dto.remarks ?? null, + inspectedById: dto.inspectedById ?? null, + inspectedAt: new Date(), + }); + + // Mirror the latest outcome onto the inventory item so loading rules can read it. + await inventoryRepo.update(inventoryId, { + inspectionStatus: dto.inspectionStatus, + inspectedAt: new Date(), + }); + + return report; + } + + async findByInventory(inventoryId: string): Promise { + return this.inspectionRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const report = await this.inspectionRepository.findById(id); + if (!report) { + throw new NotFoundException(`Inspection report ${id} not found`); + } + return report; + } + + async update(id: string, dto: UpdateInspectionReportDto): Promise { + const report = await this.findById(id); + + const expected = dto.expectedWeight ?? report.expectedWeight ?? null; + const actual = dto.actualWeight ?? report.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null; + + await this.inspectionRepository.update(id, { + ...(dto.reportType ? { reportType: dto.reportType } : {}), + ...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}), + ...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}), + ...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}), + ...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}), + expectedWeight: expected, + actualWeight: actual, + weightLoss, + ...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}), + ...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}), + ...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}), + }); + + if (dto.inspectionStatus) { + await this.dataSource + .getRepository(WarehouseInventory) + .update(report.inventoryId, { inspectionStatus: dto.inspectionStatus }); + } + + return this.findById(id); + } + + /** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */ + async addAttachments(reportId: string, files: Express.Multer.File[]) { + await this.findById(reportId); + if (!files?.length) return []; + return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files); + } + + listAttachments(reportId: string) { + return this.filesService.findByResource(reportId, INSPECTION_RESOURCE); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts new file mode 100644 index 000000000..ccc0bcd41 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; + +@Injectable() +export class WarehouseInventoryMovementRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInventoryMovement) repository: Repository, + ) { + super(repository); + } +} 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 new file mode 100644 index 000000000..ce8a2c188 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -0,0 +1,145 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { LoadInventoryDto } from './dto/load-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; +import { UnloadBookingDto } from './dto/unload-booking.dto'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +@ApiTags('warehouse-inventory') +@ApiBearerAuth() +@Controller('warehouse-inventory') +export class WarehouseInventoryController { + constructor( + private readonly inventoryService: WarehouseInventoryService, + private readonly scheduling: SchedulingReadFacade, + ) {} + + @Get() + @ApiOperation({ summary: 'List warehouse inventory' }) + findAll(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findAll(filter); + } + + @Get('ready-for-loading') + @ApiOperation({ summary: 'List inventory ready for loading' }) + findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findReadyForLoading(filter); + } + + @Get('inquiry') + @ApiOperation({ summary: 'Locate any item inside the warehouse' }) + inquiry(@Query() filter: InquiryWarehouseInventoryDto) { + return this.inventoryService.inquiry(filter); + } + + @Get('arrival-queue') + @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) + arrivalQueue() { + return this.inventoryService.arrivalQueue(); + } + + @Post('auto-unload-arrived') + @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) + autoUnloadArrived() { + return this.inventoryService.autoUnloadArrived(); + } + + @Post('auto-load-ready') + @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) + autoLoadReady() { + return this.inventoryService.autoLoadReady(); + } + + @Post('bookings/:bookingId/unload') + @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) + unloadBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: UnloadBookingDto, + ) { + return this.inventoryService.unloadBooking(bookingId, dto); + } + + @Post(':id/gate-clearance') + @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) + gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.gateClearance(id, performedBy); + } + + @Get('loadable-wagons') + @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) + loadableWagons() { + return this.scheduling.listLoadableWagons(); + } + + @Get('booking/:bookingId/schedule') + @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) + bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.scheduling.getBookingSchedule(bookingId); + } + + @Post('receive') + @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) + receive(@Body() dto: ReceiveWarehouseInventoryDto) { + return this.inventoryService.receive(dto); + } + + @Post('reserve') + @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) + reserve(@Body() dto: ReserveInventoryDto) { + return this.inventoryService.reserve(dto); + } + + @Get(':id/movements') + @ApiOperation({ summary: 'Inventory movement history' }) + movements(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findMovements(id); + } + + @Get(':id/activity') + @ApiOperation({ summary: 'Inventory activity log' }) + activity(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findActivity(id); + } + + @Get(':id/loadings') + @ApiOperation({ summary: 'Loading records for an inventory item' }) + loadings(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findLoadingsByInventory(id); + } + + @Post(':id/move') + @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) + move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { + return this.inventoryService.move(id, dto); + } + + @Post(':id/store') + @ApiOperation({ summary: 'Mark received inventory as STORED' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.store(id, performedBy); + } + + @Post(':id/ready-for-loading') + @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) + readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForLoading(id, performedBy); + } + + @Post(':id/load') + @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { + return this.inventoryService.load(id, dto); + } + + @Patch(':id/dispatch') + @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) + dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.dispatch(id, performedBy); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts new file mode 100644 index 000000000..4f249cf53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +@Injectable() +export class WarehouseInventoryRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseInventory) repository: Repository) { + super(repository); + } +} 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 new file mode 100644 index 000000000..652be600c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -0,0 +1,940 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { LoadInventoryDto } from './dto/load-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; +import { UnloadBookingDto } from './dto/unload-booking.dto'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; +import { + WAREHOUSE_INVENTORY_TRANSITIONS, + WarehouseInventory, + WarehouseInventoryStatus, +} from './entities/warehouse-inventory.entity'; +import { WarehouseLoading } from './entities/warehouse-loading.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehouseLoadingRepository } from './warehouse-loading.repository'; + +/** Wagon states that may receive a load (besides being part of an existing schedule). */ +const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; + +export interface InventoryInquiryResult { + id: string; + bookingId: string | null; + bookingNumber: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + cargoDescription: string | null; + goodsId: string | null; + warehouse: { id: string; name: string; code: string } | null; + yard: { id: string; name: string; code: string } | null; + zone: { id: string; name: string; code: string } | null; + status: string; + quantity: number; + weight: number; + arrivedAt: Date | null; + readyForLoadingAt: Date | null; +} + +interface LocationNode { + maxWeight?: number | null; + capacityWeight?: number | null; + maxVolume?: number | null; + capacityContainers?: number | null; + currentWeight: number; + currentVolume: number; + currentContainers: number; +} + +// ── Batch 4.5 result/queue shapes ──────────────────────────────────────────── +interface ArrivalQueueRow { + bookingId: string; + bookingReference: string; + customer: string | null; + cargo: string | null; + container: string | null; + arrivalDate: Date | null; + bookingStatus: string; + inventoryId: string | null; + currentStatus: string | null; + inspectionStatus: string | null; + facility: string | null; + warehouse: string | null; + yard: string | null; + zone: string | null; +} + +export interface ArrivalQueueItem { + bookingId: string; + bookingReference: string; + customer: string | null; + cargo: string | null; + container: string | null; + facility: string | null; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryId: string | null; + currentStatus: string | null; + arrivalDate: Date | null; + inspectionStatus: string | null; + unloaded: boolean; +} + +interface DefaultLocation { + warehouseId: string; + yardId: string; + zoneId: string; + facilityId: string | null; +} + +export interface AutoUnloadResult { + processedCount: number; + skippedCount: number; + failedCount: number; + results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; +} + +export interface AutoLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +@Injectable() +export class WarehouseInventoryService { + constructor( + private readonly dataSource: DataSource, + private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly loadingRepository: WarehouseLoadingRepository, + private readonly activityLog: WarehouseActivityLogService, + private readonly scheduling: SchedulingReadFacade, + private readonly allocation: WarehouseAllocationService, + private readonly invoices: WarehouseInvoiceService, + ) {} + + /** + * Batch 6 — final terminal release / gate clearance. + * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch + * inspection / storage / loading steps — only the final release. + */ + async gateClearance(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + const blocking = await this.invoices.findBlockingInvoice(id); + if (blocking) { + throw new BadRequestException( + 'Warehouse demurrage/storage fee must be paid before terminal release.', + ); + } + const now = new Date(); + await this.inventoryRepository.update(id, { + gateClearedAt: now, + releaseDate: item.releaseDate ?? now, + }); + await this.activityLog.record({ + activityType: 'INVENTORY_DISPATCHED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Gate clearance / terminal release', + performedBy, + }); + return this.findById(id); + } + + // ── Listing ──────────────────────────────────────────────────────────── + + findAll(filter: FilterWarehouseInventoryDto): Promise { + const base = { + ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), + ...(filter.yardId ? { yardId: filter.yardId } : {}), + ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), + ...(filter.bookingId ? { bookingId: filter.bookingId } : {}), + ...(filter.cargoId ? { cargoId: filter.cargoId } : {}), + ...(filter.containerId ? { containerId: filter.containerId } : {}), + ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const where: FindManyOptions['where'] = search + ? { ...base, notes: ILike(`%${search}%`) } + : base; + + return this.inventoryRepository.findAll({ + where, + relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + } + + async findById(id: string): Promise { + const item = await this.inventoryRepository.findById(id, { + relations: { warehouse: true, yard: true, zone: true }, + }); + + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + return item; + } + + // ── Batch 4.5: Arrival / Unload / Load automation ────────────────────────── + + /** Bookings whose goods have arrived and may be unloaded into the warehouse. */ + private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT']; + + /** Arrived bookings + their current inventory/inspection state (queue view). */ + async arrivalQueue(): Promise { + const rows: ArrivalQueueRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + company.name AS "customer", + b.cargo_free_text AS "cargo", + ct.container_number AS "container", + b.scheduled_date AS "arrivalDate", + b.status AS "bookingStatus", + inv.id AS "inventoryId", + inv.status AS "currentStatus", + inv.inspection_status AS "inspectionStatus", + fac.name AS "facility", + wh.name AS "warehouse", + yard.name AS "yard", + zone.name AS "zone" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + WHERE b.status = ANY($1) AND b.deleted_at IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + [this.ARRIVED_BOOKING_STATUSES], + ); + + return rows.map((r) => ({ + bookingId: r.bookingId, + bookingReference: r.bookingReference, + customer: r.customer ?? null, + cargo: r.cargo ?? null, + container: r.container ?? null, + facility: r.facility ?? null, + warehouse: r.warehouse ?? null, + yard: r.yard ?? null, + zone: r.zone ?? null, + inventoryId: r.inventoryId ?? null, + currentStatus: r.currentStatus ?? null, + arrivalDate: r.arrivalDate ?? null, + inspectionStatus: r.inspectionStatus ?? null, + unloaded: Boolean(r.inventoryId), + })); + } + + /** First warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(): Promise { + 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" + FROM freight.warehouses wh + 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 + ORDER BY wh.created_at ASC + LIMIT 1`, + ); + return row ?? null; + } + + /** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */ + async autoUnloadArrived(): Promise { + const arrived: { + id: string; + weight: string | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + }[] = await this.dataSource.query( + `SELECT b.id, b.cargo_total_weight_vgm AS weight, + b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode" + FROM freight.bookings b + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, + [this.ARRIVED_BOOKING_STATUSES], + ); + + const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; + + if (arrived.length === 0) return result; + + const fallback = await this.pickDefaultLocation(); + + for (const booking of arrived) { + try { + // Deterministic allocation by rules; fall back to default location if no rule resolves. + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: booking.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? fallback; + if (!location) { + result.failedCount += 1; + result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); + continue; + } + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: booking.id, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', + }); + result.processedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' }); + } catch (error) { + result.failedCount += 1; + result.results.push({ + bookingId: booking.id, + status: 'FAILED', + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; + } + + /** Unload a single arrived booking into a chosen (or default) location. */ + async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { + const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + + let location: DefaultLocation | null = + dto.warehouseId && dto.yardId && dto.zoneId + ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null } + : null; + if (!location) location = await this.pickDefaultLocation(); + if (!location) { + throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading'); + } + + const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date(); + + if (existing[0]) { + await this.inventoryRepository.update(existing[0].id, { + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + status: 'RECEIVED', + arrivedAt, + notes: dto.notes ?? existing[0].notes ?? 'Unloaded', + }); + return this.findById(existing[0].id); + } + + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId, + quantity: 1, + weight: 0, + status: 'RECEIVED', + arrivedAt, + notes: dto.notes ?? 'Unloaded', + }); + return this.findById(saved.id); + } + + /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + async autoLoadReady(): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); + continue; + } + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + status: 'LOADED', + loadedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: 'Auto-loaded (PAID booking)', + }, + manager, + ); + }); + result.loadedCount += 1; + result.results.push({ inventoryId: item.id, status: 'LOADED' }); + } + + return result; + } + + // ── Receive ────────────────────────────────────────────────────────────── + + async receive(dto: ReceiveWarehouseInventoryDto): Promise { + const weight = Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; + const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; + + const id = await this.dataSource.transaction(async (manager) => { + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + if (dto.bookingId) { + await this.assertBookingExists(manager, dto.bookingId); + } + + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); + + const now = new Date(); + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId: dto.bookingId ?? null, + cargoId: dto.cargoId ?? null, + containerId: dto.containerId ?? null, + goodsId: dto.goodsId ?? null, + quantity: Number(dto.quantity) || 0, + weight, + volume: dto.volume ?? null, + status: 'RECEIVED', + arrivedAt: now, + notes: dto.notes?.trim() ?? null, + }), + ); + + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Received ${weight}kg at warehouse location`, + performedBy: dto.performedBy, + }, + manager, + ); + + return saved.id; + }); + + return this.findById(id); + } + + // ── Lifecycle transitions ──────────────────────────────────────────────── + + store(id: string, performedBy?: string): Promise { + return this.transition(id, 'STORED', { + timestampField: 'storedAt', + activityType: 'INVENTORY_STORED', + description: 'Inventory stored', + performedBy, + }); + } + + async reserve(dto: ReserveInventoryDto): Promise { + const item = await this.findById(dto.inventoryId); + + if (item.status !== 'STORED') { + throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); + } + + const status = await this.getBookingStatus(dto.bookingId); + if (!status) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); + } + if (status !== 'PAID') { + throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(dto.inventoryId, { + status: 'RESERVED', + bookingId: dto.bookingId, + reservedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RESERVED', + inventoryId: dto.inventoryId, + warehouseId: item.warehouseId, + description: `Reserved for booking ${dto.bookingId}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(dto.inventoryId); + } + + async readyForLoading(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { + throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); + } + return this.transition(id, 'READY_FOR_LOADING', { + timestampField: 'readyForLoadingAt', + activityType: 'READY_FOR_LOADING', + description: 'Inventory ready for loading', + performedBy, + preloaded: item, + }); + } + + /** + * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record. + * Reads wagon/schedule data read-only — never modifies scheduling. + */ + async load(id: string, dto: LoadInventoryDto): Promise { + const item = await this.findById(id); + + // 1. inventory status must be READY_FOR_LOADING (and not already LOADED). + this.assertTransition(item.status, 'LOADED'); + + // 2. inventory is at a valid warehouse/yard/zone location. + if (!item.warehouseId || !item.yardId || !item.zoneId) { + throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading'); + } + + // 3. wagon must exist. + const wagon = await this.scheduling.findWagon(dto.wagonId); + if (!wagon) { + throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + + // 4. wagon must be available, or already selected by an existing train schedule. + const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId); + if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`, + ); + } + + // 5. inventory must not already have a loading record. + const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } }); + if (existing.length > 0) { + throw new BadRequestException('Inventory has already been loaded'); + } + + const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0); + + await this.dataSource.transaction(async (manager) => { + const now = new Date(); + await manager.getRepository(WarehouseInventory).update(id, { + status: 'LOADED', + loadedAt: now, + }); + + await manager.getRepository(WarehouseLoading).save( + manager.getRepository(WarehouseLoading).create({ + warehouseInventoryId: id, + bookingId: item.bookingId ?? null, + wagonId: dto.wagonId, + loadedAt: now, + loadedBy: dto.loadedBy ?? null, + loadedWeight, + notes: dto.notes?.trim() ?? null, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Loaded onto wagon ${wagon.wagonNumber}`, + performedBy: dto.loadedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + // ── Loading records (Batch 3) ───────────────────────────────────────────── + + async findLoadings( + filter: { bookingId?: string; wagonId?: string }, + ): Promise> { + const where = { + ...(filter.bookingId ? { bookingId: filter.bookingId } : {}), + ...(filter.wagonId ? { wagonId: filter.wagonId } : {}), + }; + const loadings = await this.loadingRepository.findAll({ + where, + relations: { inventory: { warehouse: true, yard: true, zone: true } }, + order: { loadedAt: 'DESC' }, + }); + + // Enrich with wagon numbers (read-only lookup into the scheduling domain). + const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonNumbers = new Map(); + if (wagonIds.length > 0) { + const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( + 'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)', + [wagonIds], + ); + rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number)); + } + + return loadings.map((loading) => + Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + ); + } + + findLoadingsByInventory(inventoryId: string): Promise { + return this.loadingRepository.findAll({ + where: { warehouseInventoryId: inventoryId }, + order: { loadedAt: 'DESC' }, + }); + } + + async dispatch(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DISPATCHED'); + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DISPATCHED', + dispatchedAt: new Date(), + }); + // Item physically leaves the warehouse — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.activityLog.record( + { + activityType: 'INVENTORY_DISPATCHED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Inventory dispatched', + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + // ── Movement ────────────────────────────────────────────────────────────── + + async move(id: string, dto: MoveInventoryDto): Promise { + const item = await this.findById(id); + if (item.status === 'DISPATCHED') { + throw new BadRequestException('Dispatched inventory cannot be moved'); + } + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; + + await this.dataSource.transaction(async (manager) => { + const { warehouse } = await this.validateLocation(manager, dto); + + // Capacity check at the destination (item is added there). + const dest = await this.loadLocation(manager, dto); + this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); + this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); + + // Free the old location, occupy the new one. + await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await manager.getRepository(WarehouseInventory).update(id, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: id, + fromWarehouseId: from.warehouseId, + fromYardId: from.yardId, + fromZoneId: from.zoneId, + toWarehouseId: dto.warehouseId, + toYardId: dto.yardId, + toZoneId: dto.zoneId, + remarks: dto.remarks?.trim() ?? null, + movedBy: dto.movedBy ?? 'system', + movedAt: new Date(), + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: warehouse.id, + description: dto.remarks?.trim() || 'Inventory moved', + performedBy: dto.movedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + findMovements(id: string): Promise { + return this.dataSource.getRepository(WarehouseInventoryMovement).find({ + where: { inventoryId: id }, + order: { movedAt: 'DESC' }, + }); + } + + findActivity(id: string): Promise { + return this.activityLog.findByInventory(id); + } + + // ── Inquiry (Batch 1) ────────────────────────────────────────────────── + + async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const qb = this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') + .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') + .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') + .addSelect('booking.reference', 'b_reference') + .addSelect('company.name', 'c_name') + .addSelect('container.container_number', 'ct_number') + .addSelect('cargo.description', 'cg_description') + .addSelect('cargo_type.cargo_type_name', 'cgt_name') + .orderBy('inv.created_at', 'DESC'); + + if (filter.bookingNumber?.trim()) { + qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); + } + if (filter.containerNumber?.trim()) { + qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); + } + if (filter.cargoType?.trim()) { + qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` }); + } + if (filter.goodsName?.trim()) { + qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` }); + } + if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId }); + if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId }); + if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId }); + if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status }); + + const { entities, raw } = await qb.getRawAndEntities(); + + return entities.map((inv, index) => { + const row = raw[index] ?? {}; + return { + id: inv.id, + bookingId: inv.bookingId ?? null, + bookingNumber: row.b_reference ?? null, + customerName: row.c_name ?? null, + containerNumber: row.ct_number ?? null, + cargoType: row.cgt_name ?? null, + cargoDescription: row.cg_description ?? null, + goodsId: inv.goodsId ?? null, + warehouse: inv.warehouse + ? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code } + : null, + yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, + zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, + status: inv.status, + quantity: Number(inv.quantity), + weight: Number(inv.weight), + arrivedAt: inv.arrivedAt ?? null, + readyForLoadingAt: inv.readyForLoadingAt ?? null, + }; + }); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async transition( + id: string, + to: WarehouseInventoryStatus, + opts: { + timestampField: keyof WarehouseInventory; + activityType: Parameters[0]['activityType']; + description: string; + performedBy?: string; + preloaded?: WarehouseInventory; + }, + ): Promise { + const item = opts.preloaded ?? (await this.findById(id)); + this.assertTransition(item.status, to); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: to, + [opts.timestampField]: new Date(), + }); + await this.activityLog.record( + { + activityType: opts.activityType, + inventoryId: id, + warehouseId: item.warehouseId, + description: opts.description, + performedBy: opts.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { + if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { + throw new BadRequestException(`Invalid transition ${from} → ${to}`); + } + } + + private async validateLocation( + manager: EntityManager, + dto: { warehouseId: string; yardId: string; zoneId: string }, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const { warehouse, yard, zone } = await this.loadLocation(manager, dto); + + if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); + if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); + if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); + if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); + if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); + + return { warehouse, yard, zone }; + } + + private async loadLocation( + manager: EntityManager, + dto: { warehouseId: string; yardId: string; zoneId: string }, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); + if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } }); + if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); + const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); + if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); + return { warehouse, yard, zone }; + } + + private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { + const rows = await manager.query( + 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + if (!rows || rows.length === 0) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + + private async getBookingStatus(bookingId: string): Promise { + const rows = await this.dataSource.query( + 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + return rows?.[0]?.status ?? null; + } + + private assertCapacity( + label: string, + node: LocationNode, + weightAdd: number, + volumeAdd: number, + containerAdd: number, + ): void { + const maxWeight = node.maxWeight ?? node.capacityWeight; + if (maxWeight != null) { + const projected = Number(node.currentWeight) + weightAdd; + if (projected > Number(maxWeight)) { + throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`); + } + } + if (node.maxVolume != null && volumeAdd > 0) { + const projected = Number(node.currentVolume) + volumeAdd; + if (projected > Number(node.maxVolume)) { + throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`); + } + } + if (node.capacityContainers != null && containerAdd > 0) { + const projected = Number(node.currentContainers) + containerAdd; + if (projected > Number(node.capacityContainers)) { + throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`); + } + } + } + + private async applyCapacityDelta( + manager: EntityManager, + warehouseId: string, + yardId: string, + zoneId: string, + weight: number, + volume: number, + containers: number, + sign: 1 | -1, + ): Promise { + const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); + const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ + [Warehouse, warehouseId], + [WarehouseYard, yardId], + [WarehouseZone, zoneId], + ]; + + for (const [entity, id] of targets) { + if (weight) await apply(entity, { id }, 'currentWeight', weight); + if (volume) await apply(entity, { id }, 'currentVolume', volume); + if (containers) await apply(entity, { id }, 'currentContainers', containers); + } + } +} 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 new file mode 100644 index 000000000..da818b5a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; + +@ApiTags('warehouse-fee-invoices') +@ApiBearerAuth() +@Controller() +export class WarehouseInvoiceController { + constructor(private readonly invoiceService: WarehouseInvoiceService) {} + + @Post('warehouse-inventory/:id/generate-fee-invoice') + @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) + generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { + return this.invoiceService.generateForInventory(id, dto); + } + + @Get('warehouse-inventory/:id/fee-invoices') + @ApiOperation({ summary: 'List fee invoices for an inventory item' }) + listForInventory(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.listForInventory(id); + } + + @Get('bookings/:id/warehouse-fee-invoices') + @ApiOperation({ summary: 'List warehouse fee invoices for a booking' }) + listForBooking(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.listForBooking(id); + } + + @Get('warehouse-fee-invoices') + @ApiOperation({ summary: 'List / filter warehouse fee invoices' }) + findAll( + @Query('status') status?: string, + @Query('invoiceType') invoiceType?: string, + @Query('warehouseId') warehouseId?: string, + @Query('facilityId') facilityId?: string, + @Query('customerId') customerId?: string, + @Query('bookingId') bookingId?: string, + ) { + return this.invoiceService.findAll({ + status: status as never, + invoiceType: invoiceType as never, + warehouseId, + facilityId, + customerId, + bookingId, + }); + } + + @Get('warehouse-fee-invoices/:id') + @ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.findById(id); + } + + @Patch('warehouse-fee-invoices/:id/cancel') + @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) + cancel(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.cancel(id); + } + + @Post('warehouse-fee-invoices/:id/pay') + @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) + pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { + return this.invoiceService.pay(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 new file mode 100644 index 000000000..d58a74b7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -0,0 +1,214 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { + WarehouseFeeInvoice, + WarehouseInvoiceStatus, + WarehouseInvoiceType, +} from './entities/warehouse-fee-invoice.entity'; +import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; +import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; +import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; +import { WarehouseFeeService } from './warehouse-fee.service'; + +interface GenerateOptions { + confirmZero?: boolean; + performedBy?: string; +} + +export interface PayInvoiceDto { + amount: number; + method?: string; + reference?: string; +} + +/** Invoices that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; +const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; + +@Injectable() +export class WarehouseInvoiceService { + constructor( + private readonly dataSource: DataSource, + private readonly invoiceRepository: WarehouseFeeInvoiceRepository, + private readonly itemRepository: WarehouseFeeInvoiceItemRepository, + private readonly feeService: WarehouseFeeService, + ) {} + + // ── Generation ─────────────────────────────────────────────────────────── + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + const [item] = await this.dataSource.query( + `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", + w.facility_id AS "facilityId", + b.company_id AS "customerId", b.freight_type AS "freightType" + 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 + WHERE inv.id = $1 AND inv.deleted_at IS NULL`, + [inventoryId], + ); + if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + + // Dedup: only one active (non-cancelled) invoice per inventory item. + const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); + if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { + throw new ConflictException( + 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + ); + } + + const previews = await this.feeService.previewForInventory(inventoryId); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + + const items = previews + .filter((p) => p.amount > 0) + .map((p) => { + const feeType: WarehouseFeeType = + p.ruleType === 'STORAGE_FEE' + ? 'STORAGE_FEE' + : isContainer + ? 'CONTAINER_DEMURRAGE' + : 'BULK_DEMURRAGE'; + return { + feeRuleId: p.ruleId, + feeType, + description: + p.ruleType === 'STORAGE_FEE' + ? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free` + : `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, + quantity: p.chargeableDays, + unitRate: p.ratePerDay, + amount: p.amount, + currency: p.currency, + chargeableDays: p.chargeableDays, + freeDays: p.freeDays, + }; + }); + + const subtotal = items.reduce((s, i) => s + i.amount, 0); + const total = subtotal; // tax model can be layered on later + + if (total <= 0 && !opts.confirmZero) { + throw new BadRequestException('No payable warehouse fee found for this item.'); + } + + const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); + const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const invoiceType: WarehouseInvoiceType = + hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + + const currency = items[0]?.currency ?? 'USD'; + const now = new Date(); + const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + + const invoice = await this.invoiceRepository.create({ + invoiceNumber: await this.nextInvoiceNumber(), + bookingId: item.bookingId ?? null, + customerId: item.customerId ?? null, + inventoryId, + facilityId: item.facilityId ?? null, + warehouseId: item.warehouseId ?? null, + yardId: item.yardId ?? null, + zoneId: item.zoneId ?? null, + invoiceType, + status: 'ISSUED', + subtotalAmount: subtotal, + taxAmount: 0, + totalAmount: total, + paidAmount: 0, + balanceAmount: total, + currency, + periodStart: item.arrivedAt ?? null, + periodEnd, + issuedAt: now, + payments: [], + notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + }); + + for (const it of items) { + await this.itemRepository.create({ invoiceId: invoice.id, ...it }); + } + + return this.findById(invoice.id); + } + + /** WHF-YYYYMMDD-00001 — sequential per day. */ + private async nextInvoiceNumber(): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; + const prefix = `WHF-${ymd}-`; + const [row] = await this.dataSource.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, '0')}`; + } + + // ── Reads ──────────────────────────────────────────────────────────────── + async findById(id: string): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; + } + + listForInventory(inventoryId: string): Promise { + return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); + } + + listForBooking(bookingId: string): Promise { + return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); + } + + findAll(filter: Partial>): Promise { + const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); + return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); + } + + // ── State changes ──────────────────────────────────────────────────────── + async cancel(id: string): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); + const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); + return updated as WarehouseFeeInvoice; + } + + /** Record a payment against the invoice and sync status (links to existing payment flow). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); + if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); + if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); + + const paidAmount = Number(invoice.paidAmount) + dto.amount; + const total = Number(invoice.totalAmount); + const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); + const fullyPaid = paidAmount >= total; + + const payments = [ + ...(invoice.payments ?? []), + { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, + ]; + + const updated = await this.invoiceRepository.update(id, { + paidAmount: Math.round(paidAmount * 100) / 100, + balanceAmount: balance, + status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', + paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, + payments, + }); + return updated as WarehouseFeeInvoice; + } + + // ── Release blocking ────────────────────────────────────────────────────── + /** Returns the first unpaid invoice that blocks terminal release, or null. */ + async findBlockingInvoice(inventoryId: string): Promise { + const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); + return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts new file mode 100644 index 000000000..45773c0f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseLoading } from './entities/warehouse-loading.entity'; + +@Injectable() +export class WarehouseLoadingRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseLoading) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts new file mode 100644 index 000000000..aab8e18b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +@ApiTags('warehouse-loadings') +@ApiBearerAuth() +@Controller('warehouse-loadings') +export class WarehouseLoadingsController { + constructor(private readonly inventoryService: WarehouseInventoryService) {} + + @Get() + @ApiOperation({ summary: 'List wagon loading records' }) + findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) { + return this.inventoryService.findLoadings({ bookingId, wagonId }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts new file mode 100644 index 000000000..333597618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AllocationPreviewDto, + CreateAllocationRuleDto, + UpdateAllocationRuleDto, +} from './dto/allocation-rule.dto'; +import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; + +@ApiTags('warehouse-rules') +@ApiBearerAuth() +@Controller() +export class WarehouseRulesController { + constructor( + private readonly allocationService: WarehouseAllocationService, + private readonly feeService: WarehouseFeeService, + ) {} + + // ── Allocation rules ─────────────────────────────────────────────────────── + @Get('warehouse-allocation-rules') + @ApiOperation({ summary: 'List warehouse allocation rules' }) + listAllocationRules() { + return this.allocationService.listRules(); + } + + @Post('warehouse-allocation-rules') + @ApiOperation({ summary: 'Create a warehouse allocation rule' }) + createAllocationRule(@Body() dto: CreateAllocationRuleDto) { + return this.allocationService.createRule(dto); + } + + @Patch('warehouse-allocation-rules/:id') + @ApiOperation({ summary: 'Update a warehouse allocation rule' }) + updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { + return this.allocationService.updateRule(id, dto); + } + + @Delete('warehouse-allocation-rules/:id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a warehouse allocation rule' }) + deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { + return this.allocationService.deleteRule(id); + } + + @Post('warehouse-allocation/preview') + @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) + previewAllocation(@Body() dto: AllocationPreviewDto) { + return this.allocationService.resolveLocation(dto); + } + + // ── Fee rules ──────────────────────────────────────────────────────────────── + @Get('warehouse-fee-rules') + @ApiOperation({ summary: 'List storage / demurrage fee rules' }) + listFeeRules() { + return this.feeService.listRules(); + } + + @Post('warehouse-fee-rules') + @ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) + createFeeRule(@Body() dto: CreateFeeRuleDto) { + return this.feeService.createRule(dto); + } + + @Patch('warehouse-fee-rules/:id') + @ApiOperation({ summary: 'Update a fee rule' }) + updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { + return this.feeService.updateRule(id, dto); + } + + @Delete('warehouse-fee-rules/:id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a fee rule' }) + deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { + return this.feeService.deleteRule(id); + } + + @Get('warehouse-inventory/:id/fee-preview') + @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) + feePreview(@Param('id', ParseUUIDPipe) id: string) { + return this.feeService.previewForInventory(id); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts new file mode 100644 index 000000000..dcf685c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +/** + * READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling + * domain. It only reads warehouse data — it never assigns wagons, creates or + * mutates schedules, and is intentionally NOT imported by the scheduling module. + */ +@Injectable() +export class WarehouseSchedulingAdapterService { + constructor(private readonly dataSource: DataSource) {} + + private get repo() { + return this.dataSource.getRepository(WarehouseInventory); + } + + getReadyForLoadingInventory(): Promise { + return this.repo.find({ + where: { status: 'READY_FOR_LOADING' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { readyForLoadingAt: 'ASC' }, + }); + } + + getReservedInventory(): Promise { + return this.repo.find({ + where: { status: 'RESERVED' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { reservedAt: 'ASC' }, + }); + } + + getInventoryByBooking(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Inventory whose origin booking runs on the given route. Best-effort, read-only: + * matches the route's origin/destination yards against the booking's yards. + */ + async getInventoryByRoute(routeId: string): Promise { + return this.repo + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .innerJoin( + 'freight.routes', + 'route', + 'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)', + { routeId }, + ) + .orderBy('inv.created_at', 'DESC') + .getMany(); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts new file mode 100644 index 000000000..3ee0dde82 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-yards') +@ApiBearerAuth() +@Controller('warehouse-yards') +export class WarehouseYardsController { + constructor( + private readonly yardsService: WarehouseYardsService, + private readonly zonesService: WarehouseZonesService, + ) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse yard by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.yardsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { + return this.yardsService.update(id, dto); + } + + @Get(':yardId/zones') + @ApiOperation({ summary: 'List zones within a yard' }) + listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { + return this.zonesService.findByYard(yardId); + } + + @Post(':yardId/zones') + @ApiOperation({ summary: 'Create a zone within a yard' }) + createZone( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: CreateWarehouseZoneDto, + ) { + return this.zonesService.create(yardId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts new file mode 100644 index 000000000..99bbdd21f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseYard } from './entities/warehouse-yard.entity'; + +@Injectable() +export class WarehouseYardsRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseYard) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts new file mode 100644 index 000000000..f65e4593e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -0,0 +1,93 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehousesService } from './warehouses.service'; + +@Injectable() +export class WarehouseYardsService { + constructor( + private readonly yardsRepository: WarehouseYardsRepository, + private readonly warehousesService: WarehousesService, + ) {} + + findByWarehouse(warehouseId: string): Promise { + return this.yardsRepository.findAll({ + where: { warehouseId }, + relations: { zones: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const yard = await this.yardsRepository.findById(id, { + relations: { warehouse: true, zones: true }, + }); + + if (!yard) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return yard; + } + + async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise { + // Ensure the parent warehouse exists. + await this.warehousesService.findById(warehouseId); + await this.assertCodeUnique(warehouseId, dto.code.trim()); + + return this.yardsRepository.create({ + warehouseId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseYardDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.yardsRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Yard code ${code} already exists in this warehouse`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts new file mode 100644 index 000000000..30c4407f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-zones') +@ApiBearerAuth() +@Controller('warehouse-zones') +export class WarehouseZonesController { + constructor(private readonly zonesService: WarehouseZonesService) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse zone by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse zone' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { + return this.zonesService.update(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts new file mode 100644 index 000000000..94580f116 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZone } from './entities/warehouse-zone.entity'; + +@Injectable() +export class WarehouseZonesRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts new file mode 100644 index 000000000..a2f3800cd --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -0,0 +1,92 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; + +@Injectable() +export class WarehouseZonesService { + constructor( + private readonly zonesRepository: WarehouseZonesRepository, + private readonly yardsService: WarehouseYardsService, + ) {} + + findByYard(yardId: string): Promise { + return this.zonesRepository.findAll({ + where: { yardId }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const zone = await this.zonesRepository.findById(id, { + relations: { yard: { warehouse: true } }, + }); + + if (!zone) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return zone; + } + + async create(yardId: string, dto: CreateWarehouseZoneDto): Promise { + // Ensure the parent yard exists. + await this.yardsService.findById(yardId); + await this.assertCodeUnique(yardId, dto.code.trim()); + + return this.zonesRepository.create({ + yardId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseZoneDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.zonesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Zone code ${code} already exists in this yard`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts new file mode 100644 index 000000000..bb7702603 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehousesService } from './warehouses.service'; + +@ApiTags('warehouses') +@ApiBearerAuth() +@Controller('warehouses') +export class WarehousesController { + constructor( + private readonly warehousesService: WarehousesService, + private readonly yardsService: WarehouseYardsService, + private readonly dashboardService: WarehouseDashboardService, + ) {} + + @Get() + @ApiOperation({ summary: 'List warehouses' }) + findAll(@Query() filter: FilterWarehouseDto) { + return this.warehousesService.findAll(filter); + } + + @Get('dashboard') + @ApiOperation({ summary: 'Warehouse dashboard metrics' }) + dashboard() { + return this.dashboardService.getDashboard(); + } + + @Post() + @ApiOperation({ summary: 'Create warehouse' }) + create(@Body() dto: CreateWarehouseDto) { + return this.warehousesService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.warehousesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { + return this.warehousesService.update(id, dto); + } + + @Get(':warehouseId/yards') + @ApiOperation({ summary: 'List yards within a warehouse' }) + listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { + return this.yardsService.findByWarehouse(warehouseId); + } + + @Post(':warehouseId/yards') + @ApiOperation({ summary: 'Create a yard within a warehouse' }) + createYard( + @Param('warehouseId', ParseUUIDPipe) warehouseId: string, + @Body() dto: CreateWarehouseYardDto, + ) { + return this.yardsService.create(warehouseId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts new file mode 100644 index 000000000..4a08d7f28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -0,0 +1,114 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FilesModule } from '../files/files.module'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; +import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; +import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; +import { WarehouseLoading } from './entities/warehouse-loading.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; +import { WarehouseInspectionController } from './warehouse-inspection.controller'; +import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; +import { WarehouseInventoryController } from './warehouse-inventory.controller'; +import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { WarehouseLoadingRepository } from './warehouse-loading.repository'; +import { WarehouseLoadingsController } from './warehouse-loadings.controller'; +import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; +import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; +import { WarehouseInvoiceController } from './warehouse-invoice.controller'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; +import { WarehouseRulesController } from './warehouse-rules.controller'; +import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service'; +import { WarehouseYardsController } from './warehouse-yards.controller'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesController } from './warehouse-zones.controller'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; +import { WarehouseZonesService } from './warehouse-zones.service'; +import { WarehousesController } from './warehouses.controller'; +import { WarehousesRepository } from './warehouses.repository'; +import { WarehousesService } from './warehouses.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Warehouse, + WarehouseYard, + WarehouseZone, + WarehouseInventory, + WarehouseInventoryMovement, + WarehouseActivityLog, + WarehouseLoading, + WarehouseInspectionReport, + WarehouseAllocationRule, + WarehouseFeeRule, + WarehouseFeeInvoice, + WarehouseFeeInvoiceItem, + ]), + FilesModule, + ], + controllers: [ + WarehousesController, + WarehouseYardsController, + WarehouseZonesController, + WarehouseInventoryController, + WarehouseLoadingsController, + WarehouseInspectionController, + WarehouseRulesController, + WarehouseInvoiceController, + ], + providers: [ + WarehousesRepository, + WarehouseYardsRepository, + WarehouseZonesRepository, + WarehouseInventoryRepository, + WarehouseInventoryMovementRepository, + WarehouseActivityLogRepository, + WarehouseLoadingRepository, + WarehouseInspectionRepository, + WarehouseAllocationRuleRepository, + WarehouseFeeRuleRepository, + WarehouseFeeInvoiceRepository, + WarehouseFeeInvoiceItemRepository, + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + WarehouseActivityLogService, + WarehouseDashboardService, + WarehouseInspectionService, + WarehouseAllocationService, + WarehouseFeeService, + WarehouseInvoiceService, + WarehouseSchedulingAdapterService, + SchedulingReadFacade, + ], + exports: [ + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + WarehouseAllocationService, + WarehouseFeeService, + WarehouseSchedulingAdapterService, + ], +}) +export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts new file mode 100644 index 000000000..b88ddab50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; + +@Injectable() +export class WarehousesRepository extends BaseRepository { + constructor(@InjectRepository(Warehouse) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts new file mode 100644 index 000000000..3cbcc6833 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -0,0 +1,109 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike } from 'typeorm'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { Warehouse } from './entities/warehouse.entity'; +import { WarehousesRepository } from './warehouses.repository'; + +@Injectable() +export class WarehousesService { + constructor(private readonly warehousesRepository: WarehousesRepository) {} + + async findAll(filter: FilterWarehouseDto): Promise { + const where: FindManyOptions['where'] = { + ...(filter.type ? { type: filter.type } : {}), + ...(filter.stationId ? { stationId: filter.stationId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const whereClauses = search + ? [ + { ...where, name: ILike(`%${search}%`) }, + { ...where, code: ILike(`%${search}%`) }, + { ...where, locationName: ILike(`%${search}%`) }, + ] + : where; + + return this.warehousesRepository.findAll({ + where: whereClauses, + relations: { facility: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const warehouse = await this.warehousesRepository.findById(id, { + relations: { facility: true, yards: { zones: true } }, + }); + + if (!warehouse) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return warehouse; + } + + async create(dto: CreateWarehouseDto): Promise { + await this.assertCodeUnique(dto.code.trim()); + + return this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + facilityId: dto.facilityId ?? null, + locationName: dto.locationName?.trim() ?? null, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + facilityId: dto.facilityId ?? existing.facilityId, + locationName: dto.locationName?.trim() ?? existing.locationName, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(code: string, ignoreId?: string): Promise { + const [existing] = await this.warehousesRepository.findAll({ where: { code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Warehouse code ${code} already exists`); + } + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts new file mode 100644 index 000000000..b6a6484f8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -0,0 +1,47 @@ +import { AppDataSource } from '../data-source'; +import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet'; + +async function seedEdRWagons() { + await AppDataSource.initialize(); + + const queryRunner = AppDataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + await new SeedEdRWagonFleet1750400000000().up(queryRunner); + + const [summary] = await queryRunner.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2, + COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4, + COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3, + COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2, + COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3, + COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready, + COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready + FROM freight.wagons w + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + + await queryRunner.commitTransaction(); + + console.log('Seeded EDR wagon fleet:', summary); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + await AppDataSource.destroy(); + } +} + +seedEdRWagons().catch((error) => { + console.error('Failed to seed EDR wagon fleet:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts new file mode 100644 index 000000000..cb4038097 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts @@ -0,0 +1,170 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Facility } from '../modules/facilities/entities/facility.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.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'; + +/** + * Test data seeder for Batch 1-4 warehouse system. + * Creates Indode facility with warehouses, yards, zones, and sample inventory + * in all status states (RECEIVED, STORED, RESERVED, READY_FOR_LOADING, LOADED, DISPATCHED). + */ +@Injectable() +export class Batch14TestDataSeeder { + private readonly logger = new Logger(Batch14TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const facilityRepo = this.dataSource.getRepository(Facility); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const yardRepo = this.dataSource.getRepository(WarehouseYard); + const zoneRepo = this.dataSource.getRepository(WarehouseZone); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + // Check if facility already exists + const existingFacility = await facilityRepo.findOne({ + where: { code: 'INDODE_TEST' }, + }); + + if (existingFacility) { + this.logger.log('Batch 1-4 test data already seeded, skipping'); + return; + } + + // Create Facility + const facility = await facilityRepo.save( + facilityRepo.create({ + code: 'INDODE_TEST', + name: 'Indode Test Facility', + facilityType: 'DRY_PORT', + facilityStatus: 'ACTIVE', + locationName: 'Indode', + country: 'Djibouti', + city: 'Djibouti', + isActive: true, + capacity: 100000, + }), + ); + this.logger.log(`Created facility: ${facility.code}`); + + // Create Warehouse + const warehouse = await warehouseRepo.save( + warehouseRepo.create({ + code: 'TEST_WH_001', + name: 'Test Warehouse 1', + type: 'OPEN_WAREHOUSE', + locationName: 'Test Location', + status: 'ACTIVE', + isActive: true, + facilityId: facility.id, + capacityWeight: 50000, + capacityContainers: 500, + maxWeight: 50000, + maxVolume: 10000, + }), + ); + this.logger.log(`Created warehouse: ${warehouse.code}`); + + // Create Yards + const yard1 = await yardRepo.save( + yardRepo.create({ + warehouseId: warehouse.id, + code: 'YARD_001', + name: 'Container Yard 1', + type: 'CONTAINER_YARD', + status: 'ACTIVE', + isActive: true, + capacityWeight: 25000, + capacityContainers: 250, + maxWeight: 25000, + maxVolume: 5000, + }), + ); + + const yard2 = await yardRepo.save( + yardRepo.create({ + warehouseId: warehouse.id, + code: 'YARD_002', + name: 'Bulk Yard 1', + type: 'BULK_YARD', + status: 'ACTIVE', + isActive: true, + capacityWeight: 25000, + capacityContainers: 100, + maxWeight: 25000, + maxVolume: 5000, + }), + ); + this.logger.log(`Created yards: ${yard1.code}, ${yard2.code}`); + + // Create Zones + const zone1 = await zoneRepo.save( + zoneRepo.create({ + yardId: yard1.id, + code: 'ZONE_001', + name: 'Container Zone A', + type: 'CONTAINER_ZONE', + status: 'ACTIVE', + isActive: true, + capacityWeight: 12500, + capacityContainers: 125, + maxWeight: 12500, + maxVolume: 2500, + }), + ); + + const zone2 = await zoneRepo.save( + zoneRepo.create({ + yardId: yard2.id, + code: 'ZONE_002', + name: 'Bulk Zone A', + type: 'BULK_ZONE', + status: 'ACTIVE', + isActive: true, + capacityWeight: 12500, + capacityContainers: 50, + maxWeight: 12500, + maxVolume: 2500, + }), + ); + this.logger.log(`Created zones: ${zone1.code}, ${zone2.code}`); + + // Create inventory in all statuses for testing + const statuses = ['RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'] as const; + const now = new Date(); + + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const zone = i < 3 ? zone1 : zone2; + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse.id, + yardId: zone.yardId, + zoneId: zone.id, + status: status as any, + quantity: 100 + i * 10, + weight: 500 + i * 50, + volume: 100 + i * 10, + arrivedAt: new Date(now.getTime() - i * 3600000), + storedAt: status !== 'RECEIVED' ? new Date(now.getTime() - (i - 1) * 3600000) : null, + reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + readyForLoadingAt: ['READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + loadedAt: ['LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + dispatchedAt: status === 'DISPATCHED' ? new Date() : null, + }), + ); + } + this.logger.log('Created 6 test inventory items in all statuses'); + + this.logger.log('✅ Batch 1-4 test data seeded successfully'); + } catch (error) { + this.logger.error(`Batch 1-4 seeder failed: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index f4931f77b..b391d3f9e 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -42,8 +42,13 @@ export class DemoFreightDataSeeder { async run() { await this.dataSource.transaction(async (manager) => { - await this.seedWagons(manager); - await this.seedApprovalRules(manager); + // Demo freight data (wagons + approval rules) disabled — keep only the + // 4 staff users. The seeders are retained for easy re-enabling; flip + // SEED_DEMO_FREIGHT_DATA=true to run them. + if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') { + await this.seedWagons(manager); + await this.seedApprovalRules(manager); + } await this.seedStaffUsers(manager); }); } diff --git a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts new file mode 100644 index 000000000..4b4ae23f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Facility } from '../modules/facilities/entities/facility.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseYard, type WarehouseYardType } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone, type WarehouseZoneType } from '../modules/warehouses/entities/warehouse-zone.entity'; + +const INDODE_FACILITY = { + code: 'INDODE_DRY_PORT', + name: 'Indode Multipurpose Dry Port', + description: 'Main facility for container and cargo handling', + facilityType: 'DRY_PORT' as const, + facilityStatus: 'ACTIVE' as const, + locationName: 'Indode', + country: 'Djibouti', + city: 'Djibouti', + address: 'Indode, Djibouti', + latitude: 11.5447, + longitude: 43.145, + capacity: 50000, + isActive: true, + notes: 'Primary dry port for container consolidation and distribution', +}; + +const WAREHOUSES = [ + { + name: 'Open Warehouse - Indode', + code: 'INDODE_OPEN', + type: 'OPEN_WAREHOUSE' as const, + locationName: 'Indode Open', + capacityWeight: 25000, + capacityContainers: 500, + maxWeight: 25000, + maxVolume: 5000, + status: 'ACTIVE' as const, + isActive: true, + }, + { + name: 'Closed Warehouse - Indode', + code: 'INDODE_CLOSED', + type: 'CLOSED_WAREHOUSE' as const, + locationName: 'Indode Closed', + capacityWeight: 20000, + capacityContainers: 400, + maxWeight: 20000, + maxVolume: 4000, + status: 'ACTIVE' as const, + isActive: true, + }, +]; + +const YARD_TYPES = [ + 'CONTAINER_YARD', + 'BULK_YARD', + 'GENERAL_CARGO_YARD', + 'HAZARDOUS_YARD', + 'COLD_STORAGE_YARD', +] as const; + +@Injectable() +export class IndodeFacilitySeeder { + private readonly logger = new Logger(IndodeFacilitySeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + await this.dataSource.transaction(async (manager) => { + const facilityRepo = manager.getRepository(Facility); + const warehouseRepo = manager.getRepository(Warehouse); + const yardRepo = manager.getRepository(WarehouseYard); + const zoneRepo = manager.getRepository(WarehouseZone); + + // Ensure facility exists + const facility = await facilityRepo.findOne({ + where: { code: INDODE_FACILITY.code }, + }); + + if (facility) { + this.logger.log('Indode facility already exists, skipping seed'); + return; + } + + const newFacility = facilityRepo.create(INDODE_FACILITY); + const savedFacility = await facilityRepo.save(newFacility); + this.logger.log(`Created facility: ${savedFacility.code}`); + + // Create warehouses for the facility + for (const warehouseData of WAREHOUSES) { + try { + const warehouse = await warehouseRepo.findOne({ + where: { code: warehouseData.code }, + }); + + if (warehouse) { + this.logger.log(`Warehouse ${warehouseData.code} already exists, skipping`); + continue; + } + + const newWarehouse = warehouseRepo.create({ + ...warehouseData, + facilityId: savedFacility.id, + }); + const savedWarehouse = await warehouseRepo.save(newWarehouse); + this.logger.log(`Created warehouse: ${savedWarehouse.code} under facility ${savedFacility.code}`); + + // Create 11 yards per warehouse + await this.createYardsForWarehouse(yardRepo, zoneRepo, savedWarehouse); + } catch (warehouseError) { + this.logger.warn( + `Failed to create warehouse ${warehouseData.code}: ${warehouseError instanceof Error ? warehouseError.message : String(warehouseError)}`, + ); + } + } + + this.logger.log( + 'Indode Multipurpose Dry Port facility seeded successfully with 2 warehouses and 11 yards each', + ); + }); + } catch (error) { + this.logger.error( + `IndodeFacilitySeeder error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async createYardsForWarehouse( + yardRepo: any, + zoneRepo: any, + warehouse: Warehouse, + ): Promise { + const baseCapacityWeight = 5000; + const baseCapacityContainers = 100; + const yardCount = 11; + + for (let i = 0; i < yardCount; i++) { + const yardType: WarehouseYardType = i < YARD_TYPES.length ? YARD_TYPES[i] : 'GENERAL_CARGO_YARD'; + const yardCode = `${warehouse.code}_YARD_${String(i + 1).padStart(2, '0')}`; + + const existingYard = await yardRepo.findOne({ where: { code: yardCode } }); + if (existingYard) { + this.logger.log(`Yard ${yardCode} already exists, skipping`); + continue; + } + + const yardData = { + warehouseId: warehouse.id, + name: `${warehouse.code} ${yardType.replace(/_/g, ' ')} ${String(i + 1).padStart(2, '0')}`, + code: yardCode, + type: yardType, + capacityWeight: baseCapacityWeight, + capacityContainers: baseCapacityContainers, + maxWeight: baseCapacityWeight, + maxVolume: baseCapacityWeight / 2, + status: 'ACTIVE' as const, + isActive: true, + }; + + const newYard = yardRepo.create(yardData); + const savedYard = (await yardRepo.save(newYard)) as WarehouseYard; + this.logger.log(`Created yard: ${savedYard.code}`); + + // Create default zone for the yard + const zoneType: WarehouseZoneType = yardType.replace('_YARD', '_ZONE') as WarehouseZoneType; + const zoneCode = `${savedYard.code}_ZONE_A`; + + const zoneData = { + yardId: savedYard.id, + name: `${savedYard.name} Zone A`, + code: zoneCode, + type: zoneType, + capacityWeight: (baseCapacityWeight ?? 1000) / 2, + capacityContainers: (baseCapacityContainers ?? 100) / 2, + maxWeight: (baseCapacityWeight ?? 1000) / 2, + maxVolume: (baseCapacityWeight ?? 500) / 2, + status: 'ACTIVE' as const, + isActive: true, + }; + + const newZone = zoneRepo.create(zoneData); + await zoneRepo.save(newZone); + this.logger.log(`Created zone: ${zoneCode}`); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a384d5956..e8879ba0e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,12 +6,15 @@ import { LayoutGrid, Network, Paperclip, + PackageCheck, + Send, Settings, SlidersHorizontal, Train, Truck, Container, Package, + PackageOpen, Users, Wallet, //TrainTrack, @@ -41,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; @@ -53,6 +57,17 @@ import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/perm import { RequirePermission } from "./components/auth/RequirePermission"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -143,6 +158,61 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ // }, ], }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, { title: "Administration", items: [ @@ -303,6 +373,18 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } @@ -444,6 +526,8 @@ const App = () => { } /> + } /> + } /> } /> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // eslint-disable-next-line no-console + console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack); + } + + handleReset = () => this.setState({ error: null }); + + render() { + const { error } = this.state; + + if (!error) return this.props.children; + + return ( +
+
+

Something went wrong

+

+ A render error was caught. Details below — share this with the developer. +

+
+            {error.message}
+            {"\n\n"}
+            {error.stack}
+          
+ +
+
+ ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx new file mode 100644 index 000000000..caa554a10 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { PackageCheck } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useDeliverCargo } from '@/hooks/useCargoes'; +import { useToast } from '@/hooks/use-toast'; + +/** + * Customer Pickup + Proof of Delivery capture for a LOADED cargo. + * Records receiver name, pickup date and remarks, then marks the cargo DELIVERED. + */ +export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) { + const [open, setOpen] = useState(false); + const [receiverName, setReceiverName] = useState(''); + const [pickupDate, setPickupDate] = useState(''); + const [deliveryRemarks, setDeliveryRemarks] = useState(''); + const deliver = useDeliverCargo(); + const { toast } = useToast(); + + const handleDeliver = async () => { + if (!receiverName.trim()) { + toast({ title: 'Receiver name is required', variant: 'destructive' }); + return; + } + try { + await deliver.mutateAsync({ + id: cargoId, + payload: { + receiverName: receiverName.trim(), + pickupDate: pickupDate ? new Date(pickupDate).toISOString() : undefined, + deliveryRemarks: deliveryRemarks.trim() || undefined, + }, + }); + toast({ title: 'Delivered', description: 'Proof of delivery recorded; cargo marked delivered.' }); + setOpen(false); + setReceiverName(''); + setPickupDate(''); + setDeliveryRemarks(''); + onSuccess?.(); + } catch (error) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + 'Could not record delivery.'; + toast({ title: 'Delivery failed', description: String(message), variant: 'destructive' }); + } + }; + + return ( + + + + + + + Customer Pickup & Proof of Delivery + +
+
+ + setReceiverName(e.target.value)} + /> +
+
+ + setPickupDate(e.target.value)} + /> +
+
+ +