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/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 b96e1eae6..f58260c44 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -44,9 +44,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 +56,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: [ @@ -112,17 +116,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 { 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/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/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/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/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d5e72d860..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', 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/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/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/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/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..0c61cc445 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, @@ -53,6 +56,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 +157,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 +372,18 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } diff --git a/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..00635118d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx @@ -0,0 +1,99 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * App-wide error boundary. Without this, any render-time exception unmounts the + * React tree and the user sees a blank white screen. This surfaces the actual + * error message + stack so failures are diagnosable in place. + */ +export class ErrorBoundary extends Component { + 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)} + /> +
+
+ +