diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 82e70d3c5..5a9ae9ef9 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -60,6 +60,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; @@ -198,6 +199,7 @@ import { LoggerMiddleware } from "./logger.middleware"; EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, @@ -223,6 +225,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, @@ -260,6 +263,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, + // Dire Dawa). Idempotent; creates no yards. + await this.yardFacilitiesSeeder.run(); + // Dropdown settings are not seeded on boot; run them with // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts new file mode 100644 index 000000000..5cae30302 --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -0,0 +1,13 @@ +/** + * Goods Received Note number: `GRN---`. + * + * Shared so a GRN raised at a load/unload facility is indistinguishable from one + * raised in a warehouse — the two live in different tables + * (facility_handling_events vs warehouse_inventory), and a second generator would + * eventually let their formats drift apart. + */ +export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts new file mode 100644 index 000000000..620eebc14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its + * destination yard, but only some yards have the equipment to do it. EDR's + * load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad — + * and the set grows, so it must be data, not a constant. + * + * `yards.has_facility` marks a yard as a load/unload point; `yard_facilities` + * holds what that facility can do. Only a facility with `has_warehouse` (Indode + * today) stores cargo, and therefore accrues storage/demurrage — the rest just + * move it on and off the train. + * + * `facility_handling_events` records each load/unload and carries its GRN. + * warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so + * a facility with no warehouse could never have a row. `inventory_id` links to the + * storage record when the facility does have a warehouse. + */ +export class YardFacilities2290000000000 implements MigrationInterface { + name = 'YardFacilities2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yards + ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_facilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + has_warehouse boolean NOT NULL DEFAULT false, + equipment_notes text NULL, + 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 + ) + `); + // One facility record per yard. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard" + ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.facility_handling_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid NULL REFERENCES freight.train_schedules(id), + event_type varchar(10) NOT NULL, + grn_number varchar(60) NULL, + quantity numeric(14, 3) NULL, + weight_tons numeric(14, 3) NULL, + inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id), + performed_by varchar(120) NULL, + occurred_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_facility_handling_events_booking" + ON freight.facility_handling_events (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard" + ON freight.facility_handling_events (yard_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn" + ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`); + await queryRunner.query(` + ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note 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_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 091232611..9a1f127e8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -308,7 +306,7 @@ export class BookingPricingService { totalVgmTons: qty * vgm, isReefer: ct.isReefer, }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..a978fd22b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -110,7 +110,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 72211925f..aeae7d10a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 588e4f1ed..d9dd53f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -438,7 +439,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..c054b3531 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..adaa12479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a0c2b42a4..826bc33b0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -1053,7 +1054,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1513,7 +1514,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1651,7 +1652,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5b8a3ddca..d96bcf492 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -190,12 +190,16 @@ export class PaymentService { */ async initiate(input: InitiateIntentInput): Promise { try { + + + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - amountMinor: input.amountMinor, + // amountMinor: input.amountMinor, + amountMinor:1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, diff --git a/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts new file mode 100644 index 000000000..ed64c1edb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts @@ -0,0 +1,15 @@ +/** + * Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon, + * 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts + * never under-book. + */ +export function wagonsPerUnitForSize(sizeFt?: number | null): number { + const size = Number(sizeFt); + if (!Number.isFinite(size) || size <= 0) return 1; + return size >= 40 ? 1 : 0.5; +} + +/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */ +export function containersPerWagonForSize(sizeFt?: number | null): number { + return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt))); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 6424f013e..0b3334431 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -29,8 +29,7 @@ export class PriorityConfigsController { @Get('next-range') @RuleEngineView('priority-configs') @ApiOperation({ - summary: - "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + summary: 'Where the next contiguous range for a type (and currency) must start', }) nextRange( @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts new file mode 100644 index 000000000..9972ab06a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { isSuperAdmin } from '../../../common/freight-permission.util'; +import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { RateChangeStatus } from '../entities/rate-change-request.entity'; +import { RateChangeRequestsService } from '../services/rate-change-requests.service'; + +/** + * Edits to LIVE rates. Staff with `manage` propose (submit); only holders of + * `approve` decide. Until a change is approved the live rate keeps its current + * value, so pricing never moves on an unapproved edit. + */ +@ApiTags('rate-change-requests') +@Controller('rate-change-requests') +@ApiBearerAuth() +export class RateChangeRequestsController { + constructor(private readonly service: RateChangeRequestsService) {} + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) + submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rate change requests, optionally by status' }) + list(@Query('status') status?: RateChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Approve a rate change and put it into effect' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); + } + + @Post(':id/reject') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index a01ba4b5b..379997e6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -14,12 +13,6 @@ export class CreateContainerTypeDto { @Max(40) sizeFt!: number; - @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) - @IsNumber() - @Min(0.01) - @Transform(({ value }) => Number(value)) - wagonsPerUnit!: number; - @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 53583e3e8..295e9e72b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -17,6 +17,15 @@ export class CreateYardDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'This yard can load/unload cargo. Intercity bookings may only be loaded at their origin and unloaded at their destination when it is a facility.', + }) + @IsOptional() + @IsBoolean() + hasFacility?: boolean; + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts new file mode 100644 index 000000000..6c5dc3fbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +import { UpdateRateDto } from './update-rate.dto'; + +export class SubmitRateChangeDto { + @ApiProperty({ description: 'The LIVE rate to reprice' }) + @IsUUID() + rateId!: string; + + @ApiProperty({ + description: + 'Proposed field changes. The live rate keeps its current values until this is approved.', + type: UpdateRateDto, + }) + @ValidateNested() + @Type(() => UpdateRateDto) + update!: UpdateRateDto; +} + +export class DecideRateChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 2347426ca..642f6942e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity { @Column({ name: 'size_ft', type: 'smallint', nullable: true }) sizeFt!: number; - @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) - wagonsPerUnit!: number; - @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts new file mode 100644 index 000000000..00a1c0ba3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Rate } from './rate.entity'; + +export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed edit to a LIVE rate, awaiting approval. + * + * A LIVE rate is what pricing actually charges, so it is never mutated in + * place: the edit is filed here and the live row keeps its old value until an + * approver applies it. `payload` holds only the changed fields (an + * UpdateRateDto patch), `rateId` the rate being repriced. + * + * DRAFT rates are not covered — nothing prices off a draft, so those still + * edit directly and reach LIVE through the existing submit/approve flow. + */ +@Entity({ schema: 'freight', name: 'rate_change_requests' }) +@Index(['status']) +export class RateChangeRequest extends BaseEntity { + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate, { nullable: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate | null; + + /** Proposed field changes — an UpdateRateDto patch, changed keys only. */ + @Column({ name: 'payload', type: 'jsonb' }) + payload!: Record; + + /** + * The rate's values at submit time, for the approver's before→after diff. + * Snapshotted because the live row can move on between submit and decision. + */ + @Column({ name: 'previous_values', type: 'jsonb' }) + previousValues!: Record; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: RateChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts new file mode 100644 index 000000000..ba5a0d671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * What a yard's load/unload facility can do. One record per yard flagged + * `has_facility`. + * + * `hasWarehouse` is the line that matters: a facility with a warehouse (Indode + * today) stores cargo and therefore accrues storage/demurrage through the normal + * warehouse flow; the rest only move cargo on and off the train, so they record + * the handling event and its GRN and nothing else. + */ +@Entity({ schema: 'freight', name: 'yard_facilities' }) +@Index(['yardId']) +export class YardFacility extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Cargo can be stored here — enables the warehouse flow (storage, demurrage). */ + @Column({ name: 'has_warehouse', type: 'boolean', default: false }) + hasWarehouse!: boolean; + + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) + equipmentNotes?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 3f7f1ae97..808a102e5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -22,6 +22,14 @@ export class Yard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** + * This yard has the equipment to load/unload cargo. Intercity bookings can only + * be loaded at their origin and unloaded at their destination where this is + * true. What the facility can do lives on the YardFacility record. + */ + @Column({ name: 'has_facility', type: 'boolean', default: false }) + hasFacility!: boolean; + @Column({ name: 'display_order', type: 'int', default: 1 }) displayOrder!: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 7edcf0bbf..3a342ef62 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; +import { RateChangeRequestsController } from './controllers/rate-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -17,11 +18,13 @@ import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; +import { RateChangeRequest } from './entities/rate-change-request.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -49,11 +52,13 @@ import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; +import { RateChangeRequestsService } from './services/rate-change-requests.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -72,9 +77,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerType, PriorityConfig, PriorityRuleChangeRequest, + RateChangeRequest, ServiceType, WeightLimitRule, Yard, + YardFacility, ShippingLine, Rate, ApprovalRule, @@ -91,6 +98,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesController, PriorityConfigsController, PriorityRuleChangeRequestsController, + RateChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -121,9 +129,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesService, PriorityConfigsService, PriorityRuleChangeRequestsService, + RateChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -138,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 42ce389e1..4c40cae4b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -48,7 +48,6 @@ export class ContainerTypesService { code, label: dto.label, sizeFt: dto.sizeFt, - wagonsPerUnit: dto.wagonsPerUnit, isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts index 53cb7ed83..0b0ef1b0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for - * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range - * must start at the lowest uncovered wagon count. Caps: WAGON 50, - * CURRENCY 35, CUSTOMS 15. + * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( @@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => { ).rejects.toThrow(/overlaps existing rule/); }); - it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + it('imposes no upper ceiling on any type', async () => { await expect( - attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), - ).rejects.toThrow(/may not exceed 50/); + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, - maxWagonCount: 36, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 35/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, - maxWagonCount: 16, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 15/); + ).resolves.toBeUndefined(); }); - it('rejects any new rule once the chain covers the full range', async () => { + it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, - maxWagonCount: 51, + maxWagonCount: 120, }), - ).rejects.toThrow(/may not exceed 50/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', - minWagonCount: 1, - maxWagonCount: 1, + minWagonCount: 16, + maxWagonCount: 99, }), - ).rejects.toThrow(/already cover the full 1–15 range/); + ).resolves.toBeUndefined(); + }); + + it('still rejects a min greater than the max', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), + ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { @@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => { it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); - await expect(svc.nextRange('WAGON')).resolves.toEqual({ - nextMin: 6, - maxCap: 50, - }); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); + // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), - ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, - maxCap: 35, }); }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index ff3711e42..274ce687f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,28 +10,19 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; -/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ -export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - /** * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range - * must start. Null when the chain is already complete up to the type's cap. + * must start. The chain is unbounded above, so there is always a next start. */ function nextRangeStart( rules: Pick[], -): number | null { - const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; +): number { const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); let next = 1; for (const r of sorted) { if (r.minWagonCount > next) break; // gap before this rule — fill it next = Math.max(next, r.maxWagonCount + 1); } - if (cap != null && next > cap) return null; return next; } @@ -102,8 +93,8 @@ export class PriorityConfigsService { * - ranges never overlap — a booking matches at most one rule per type; * - ranges are contiguous from 1: a new range must START at the lowest * wagon count not yet covered (after 1–5 the next is 6–…; deleting a - * middle rule opens a gap and the next create must fill it first); - * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * middle rule opens a gap and the next create must fill it first). + * There is no upper ceiling — max wagon count is unbounded. * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { @@ -118,14 +109,6 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const cap = RANGE_CAPS[input.type]; - if (input.maxWagonCount > cap) { - throw new BadRequestException( - `${input.type} ranges may not exceed ${cap} — ` + - `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, - ); - } - const siblings = ( await this.repository.findAll({ where: { type: input.type } }) ).filter( @@ -142,12 +125,6 @@ export class PriorityConfigsService { const currentStart = input.excludeId ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null : null; - if (expectedStart == null && currentStart == null) { - throw new BadRequestException( - `${input.type} rules already cover the full 1–${cap} range — ` + - 'delete or shrink an existing rule first.', - ); - } if ( input.minWagonCount !== expectedStart && input.minWagonCount !== currentStart @@ -174,21 +151,21 @@ export class PriorityConfigsService { } /** - * Where the next range for a type/currency must start, and the type's - * ceiling — feeds the create form so the min field is auto-filled and - * locked. `nextMin` is null when the chain already covers 1..cap. + * Where the next range for a type/currency must start — feeds the create + * form so the min field is auto-filled and locked. Always a number: the + * chain has no ceiling, so another range always fits. */ async nextRange( type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', currency?: string | null, - ): Promise<{ nextMin: number | null; maxCap: number }> { + ): Promise<{ nextMin: number }> { const siblings = ( await this.repository.findAll({ where: { type } }) ).filter( (s) => type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), ); - return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + return { nextMin: nextRangeStart(siblings) }; } async remove(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts new file mode 100644 index 000000000..6c3adce66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; + +import { RateChangeRequest } from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RateChangeRequestsService } from './rate-change-requests.service'; + +/** + * The guarantee under test: editing a LIVE rate never moves the live value. + * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only + * approval applies it, and only then through RatesService (so every rate rule + * is re-checked against the state at approval time). + */ +describe('RateChangeRequestsService', () => { + const liveRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-1', + status: 'LIVE', + rateType: 'OCEAN_FREIGHT', + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + currency: 'USD', + // Postgres numeric comes back as a string — the no-op check must cope. + rateValue: '100.0000' as unknown as number, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: null, + proposedByStaffId: 'staff-1', + ...overrides, + }) as unknown as Rate; + + const build = (opts: { + rate?: Rate; + pending?: RateChangeRequest | null; + applyThrows?: Error; + } = {}) => { + const rate = opts.rate ?? liveRate(); + const saved: RateChangeRequest[] = []; + + const repo = { + findOne: jest.fn(async ({ where }: { where: Record }) => { + if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null; + return saved.find((r) => r.id === where.id) ?? opts.pending ?? null; + }), + create: jest.fn((data: Partial) => ({ id: 'req-1', ...data })), + save: jest.fn(async (entity: RateChangeRequest) => { + saved.push(entity); + return entity; + }), + find: jest.fn(async () => saved), + }; + + const rates = { + findById: jest.fn(async () => rate), + assertUpdateValid: jest.fn(async () => undefined), + applyApprovedUpdate: jest.fn(async () => { + if (opts.applyThrows) throw opts.applyThrows; + return rate; + }), + }; + + const inbox = { notify: jest.fn(async () => undefined) }; + + const service = new RateChangeRequestsService( + repo as never, + rates as never, + inbox as never, + ); + // `pending` is the very object approve/reject mutate — assert on it, not a copy. + return { service, repo, rates, inbox, pending: opts.pending }; + }; + + describe('submit', () => { + it('files a pending request instead of touching the live rate', async () => { + const { service, rates } = build(); + + const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }); + + expect(request.status).toBe('PENDING'); + expect(request.payload).toEqual({ rateValue: 200 }); + // The old value is snapshotted for the approver's diff... + expect(request.previousValues).toEqual({ rateValue: '100.0000' }); + // ...and nothing wrote to the rate itself. + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('keeps only the fields that actually changed', async () => { + const { service } = build(); + + // A form posts every field back; only rateValue differs from the live rate. + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 200, + currency: 'USD', + rateUnit: 'PER_CONTAINER', + appliesTo: 'CONTAINER', + }, + }); + + expect(request.payload).toEqual({ rateValue: 200 }); + }); + + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { + const { service } = build(); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }), + ).rejects.toThrow(/Nothing changed/); + }); + + it('refuses a rate that is not LIVE — those edit directly', async () => { + const { service } = build({ rate: liveRate({ status: 'DRAFT' }) }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(BadRequestException); + }); + + it('refuses a second pending change for the same rate', async () => { + const { service } = build({ + pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest, + }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(ConflictException); + }); + + it('validates up front so the requester hears about a bad patch, not the approver', async () => { + const { service, rates } = build(); + rates.assertUpdateValid.mockRejectedValueOnce( + new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + ); + await expect( + service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), + ).rejects.toThrow(/not valid for this rate/); + }); + }); + + describe('approve', () => { + const pendingRequest = (): RateChangeRequest => + ({ + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + }) as unknown as RateChangeRequest; + + it('applies the change through RatesService and marks it approved', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + + const decided = await service.approve('req-1', 'approver-1', 'Agreed'); + + expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 }); + expect(decided.status).toBe('APPROVED'); + expect(decided.decidedByUserId).toBe('approver-1'); + expect(decided.decisionNote).toBe('Agreed'); + }); + + it('blocks the requester from approving their own change', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('lets a super admin self-approve', async () => { + const { service } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({ + status: 'APPROVED', + }); + }); + + it('stays PENDING when applying now fails — never marks a change that did not land', async () => { + const { service, pending, repo } = build({ + pending: pendingRequest(), + applyThrows: new ConflictException('A rate for this exact combination already exists.'), + }); + + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/); + // Apply runs first, so a failure leaves the request untouched and re-decidable. + expect(pending!.status).toBe('PENDING'); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('refuses to decide an already-decided request', async () => { + const { service } = build({ + pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest, + }); + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException); + }); + }); + + describe('reject', () => { + it('never touches the rate — it simply keeps its current value', async () => { + const { service, rates } = build({ + pending: { + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + } as unknown as RateChangeRequest, + }); + + const decided = await service.reject('req-1', 'approver-1', 'Too steep'); + + expect(decided.status).toBe('REJECTED'); + expect(decided.decisionNote).toBe('Too steep'); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts new file mode 100644 index 000000000..357c67f95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -0,0 +1,241 @@ +import { NotificationAudience, NotificationType } from '@edr/types'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { + RateChangeRequest, + RateChangeStatus, +} from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RatesService } from './rates.service'; + +/** Backoffice page where both the queue and the rates live. */ +const RATES_LINK = '/dashboard/rules/rates'; + +/** Fields a change request may carry — anything else in the patch is ignored. */ +const DIFFABLE_FIELDS = [ + 'rateValue', + 'currency', + 'rateUnit', + 'appliesTo', + 'trigger', + 'tradeDirection', + 'containerTypeId', + 'cargoTypeId', +] as const; + +/** + * Approval workflow for edits to LIVE rates. + * + * A LIVE rate is what pricing charges right now, so it is never edited in + * place. The edit is filed here as a PENDING request and the live row keeps + * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 + * waits. Approval replays the edit through RatesService, so every rule + * (unit validity, pattern uniqueness) is re-checked against whatever is true + * at approval time, not at submit time. + */ +@Injectable() +export class RateChangeRequestsService { + private readonly logger = new Logger(RateChangeRequestsService.name); + + constructor( + @InjectRepository(RateChangeRequest) + private readonly repo: Repository, + private readonly rates: RatesService, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * File an edit against a LIVE rate. Validated up front so the requester + * hears about a bad unit or a pattern clash immediately rather than the + * approver hitting it days later. + */ + async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { + const rate = await this.rates.findById(dto.rateId); + if (rate.status !== 'LIVE') { + throw new BadRequestException( + `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, + ); + } + + const payload = this.changedFieldsOnly(rate, dto.update); + if (Object.keys(payload).length === 0) { + throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); + } + + // One pending edit per rate: two racing requests would both validate, then + // the second would silently overwrite the first on approval. + const inFlight = await this.repo.findOne({ + where: { rateId: dto.rateId, status: 'PENDING' }, + }); + if (inFlight) { + throw new ConflictException( + 'This rate already has a change awaiting approval. Have it approved or rejected first.', + ); + } + + await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + + const request = await this.repo.save( + this.repo.create({ + rateId: dto.rateId, + payload, + previousValues: this.snapshot(rate, payload), + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Rate change submitted', + `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, + request, + ); + return request; + } + + async list(status?: RateChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { rate: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Approve and apply. The live mutation runs FIRST — if it now fails (someone + * created a clashing rate since submit), the request stays PENDING and the + * approver sees the real error instead of a request marked approved that + * never landed. + */ + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + canSelfApprove = false, + ): Promise { + const request = await this.findPending(id); + + // Separation of duties: the requester cannot approve their own repricing — + // except super admins, who have full backoffice authority. + if (!canSelfApprove && userId && userId === request.requestedByUserId) { + throw new ForbiddenException('You cannot approve a rate change you submitted'); + } + + await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change approved', + `The rate change was approved and is now live.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** Reject — the live rate is never touched, so it simply keeps its value. */ + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change rejected', + `The rate change was rejected — the rate keeps its current value.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Keep only fields the requester actually changed. A form posts every field + * back, so without this the diff would list untouched values as changes. + */ + private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + const patch: Record = {}; + for (const field of DIFFABLE_FIELDS) { + const proposed = (update as Record)[field]; + if (proposed === undefined) continue; + if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; + patch[field] = proposed; + } + return patch; + } + + /** The live values the patch would overwrite — the "before" side of the diff. */ + private snapshot(rate: Rate, payload: Record): Record { + const before: Record = {}; + for (const field of Object.keys(payload)) { + before[field] = (rate as unknown as Record)[field] ?? null; + } + return before; + } + + /** + * rateValue arrives as a string from Postgres `numeric` but as a number from + * the form, so 100 and "100.0000" must compare equal or every submit would + * look like a change. + */ + private sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { + return numA === numB; + } + return String(a) === String(b); + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); + if (!request) throw new NotFoundException(`Rate change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); + } + return request; + } + + /** Fire-and-forget — a notification failure never blocks the workflow. */ + private notifyTeam(title: string, body: string, request: RateChangeRequest): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RATES_LINK, + data: { rateChangeRequestId: request.id, rateId: request.rateId }, + }) + .catch((err) => + this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 3cd17cf6e..b9a5e7873 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -119,12 +119,62 @@ export class RatesService { }); } - /** Update a DRAFT rate. */ + /** + * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit + * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. + */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT rates can be updated'); + throw new BadRequestException( + existing.status === 'LIVE' + ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' + : 'Only DRAFT rates can be updated', + ); } + return this.applyUpdate(existing, dto); + } + + /** + * Apply an approved change request to a LIVE rate. Same validation as a + * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching + * here has already been through approval. Only ever called by + * RateChangeRequestsService.approve. + */ + async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'LIVE') { + throw new BadRequestException( + `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, + ); + } + return this.applyUpdate(existing, dto); + } + + /** + * Validate a proposed patch against a rate without writing anything — lets a + * change request be refused at submit time instead of surprising the + * approver. Throws exactly what applying it would throw. + */ + async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { + await this.buildUpdate(await this.findById(id), dto); + } + + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { + const updates = await this.buildUpdate(existing, dto); + const updated = await this.repository.update(existing.id, updates); + if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); + return updated; + } + + /** + * The shared edit body: re-derives rateType, re-validates the unit against + * the (possibly changed) shape, and guards pattern uniqueness. Status is + * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart + * from the uniqueness read, so it doubles as the dry-run validator. + */ + private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { + const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; @@ -179,9 +229,7 @@ export class RatesService { updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - const updated = await this.repository.update(id, updates); - if (!updated) throw new NotFoundException(`Rate ${id} not found`); - return updated; + return updates; } /** Submit a DRAFT rate for CEO approval. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts new file mode 100644 index 000000000..f32b0129a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** A yard's load/unload capability, resolved for the handling flows. */ +export interface YardFacilityInfo { + yardId: string; + yardCode: string | null; + yardLabel: string | null; + /** The yard can load/unload cargo at all. */ + hasFacility: boolean; + /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ + hasWarehouse: boolean; +} + +/** + * Which yards can handle cargo, and how. + * + * A yard is a load/unload point when `yards.has_facility` is set; the matching + * `yard_facilities` record says whether it also stores cargo. Facilities without a + * warehouse move cargo on and off the train and nothing more — no storage, no + * demurrage. This is the single resolver the journey and handling flows use, so + * they can't drift on what a facility is. + */ +@Injectable() +export class YardFacilitiesService { + constructor(private readonly dataSource: DataSource) {} + + /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ + async facilityForYard(yardId: string): Promise { + const [row]: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.id = $1 AND y.deleted_at IS NULL`, + [yardId], + ); + if (!row) return null; + return { + yardId: row.yardId, + yardCode: row.yardCode, + yardLabel: row.yardLabel, + hasFacility: Boolean(row.hasFacility), + // No facility record means no warehouse, whatever the flag says. + hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse), + }; + } + + /** Every yard that can load/unload, for pickers and the intercity queues. */ + async listFacilityYards(): Promise { + const rows: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.deleted_at IS NULL + AND y.is_active = true + AND y.has_facility = true + ORDER BY y.display_order ASC, y.label ASC`, + ); + return rows.map((r) => ({ + yardId: r.yardId, + yardCode: r.yardCode, + yardLabel: r.yardLabel, + hasFacility: true, + hasWarehouse: Boolean(r.hasWarehouse), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 47b1f05bc..69eab608a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -45,6 +45,7 @@ export class YardsService { label: dto.label, country: dto.country, isActive: dto.isActive ?? true, + hasFacility: dto.hasFacility ?? false, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 8c3c92193..c19140d8a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 210, bookingContainers: [ - { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(3); @@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 40, bookingContainers: [ - { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + { quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(2); @@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, + containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; @@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => { }); }); }); + +describe('BookingBatchService — built-train wagon capacity', () => { + // A schedule created from a built train is capped by its PHYSICAL consist: + // wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the + // old weight/length math would call every one of these trains FULL, so any + // assertion below that says "not full" proves those axes are ignored. + const scheduleId = 'schedule-built'; + + const reservedBooking = (id: string) => + ({ + id, + freightType: 'BULK', + cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload + bookingContainers: [], + originYardId: 'yard-a', + destinationYardId: 'yard-b', + }) as unknown as Booking; + + const buildService = (opts: { + physicalWagons: number; + reserved: Booking[]; + maxWagons?: number; + }) => { + const schedule = { + id: scheduleId, + maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose + bookingWindowStatus: 'OPEN', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: null, + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + train: { id: 'train-built-1' }, + }, + }; + const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const genericRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => + entity?.name === 'Wagon' ? wagonRepo : genericRepo, + ), + transaction: jest.fn(), + }; + const service = new BookingBatchService( + dataSource as never, + { + findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved), + } as never, + { + findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule), + findById: jest.fn().mockResolvedValue(schedule), + } as never, + null as never, + null as never, + null as never, + null as never, + null as never, + { emitPhase: jest.fn() } as never, + null as never, + ); + return { service, wagonRepo }; + }; + + it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => { + const { service } = buildService({ + physicalWagons: 2, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxWagons: 44, // stale: the old slot cap would say 42 slots remain + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + // 1T pull cap would have been exhausted long ago under the old math. + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { + const { service } = buildService({ + physicalWagons: 1, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({ + maxWagons: 1, + allocatedWagons: 2, + remainingSlots: 0, + overAllocatedBy: 1, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index ef39ab138..5beb920d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -68,6 +68,7 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; @@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit { private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, + // forwardRef: TrainSchedulingService injects this service back (window + // refresh after adjust-consist), so the classes load in a cycle. + @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit { ? Math.ceil(booking.wagonsRequired) : 0; - // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); @@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit { } /** - * Keep schedule.max_wagons aligned with the train's boarding limit: the - * locomotive's length-derived slot count. The physical wagons currently in - * the train set do NOT cap this — bookings are admitted on length/weight - * alone and yard staff attach the wagons manually before departure. + * Keep schedule.max_wagons aligned with the train's boarding limit. A built + * train's limit is its physical consist — the wagon count staff marshalled + * (and may change via adjust-consist). Only schedules WITHOUT a built train + * fall back to the locomotive's length-derived slot count, where bookings + * are admitted on length/weight alone and yard staff attach the wagons + * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, ): Promise { - const limits = await this.capacityLimits(locomotive); - const maxWagons = limits.base.wagons; + const physicalWagons = await this.builtTrainWagonCount(schedule); + const maxWagons = + physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) @@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit { * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * - * The wagon axis is the locomotive's length-derived slot count only — the - * physical wagons currently marshalled in the train set do NOT cap it. - * Bookings are admitted on length/weight capacity and yard staff attach - * the missing wagons manually before wagon assignment. + * Two capacity regimes, decided by the schedule's train: + * - Built train (Train Builder consist with physical wagons): the consist IS + * the capacity. Wagon slots = physical wagon count; weight and length are + * NOT re-checked here — the builder and adjust-consist already enforced the + * locomotive's pull/length limits when the consist was assembled. + * - No built train (legacy schedules): the locomotive's length-derived slot + * count plus its weight/length budgets, as before — yard staff attach the + * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, ): Promise { + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) @@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit { return budget; } + /** + * Physical wagons marshalled in the schedule's built train, or null when the + * schedule has no built train (or the consist is still empty) and the legacy + * locomotive-derived capacity must apply. This count is what caps a built + * train's bookings: 50 wagons coupled → 50 wagon slots, no more. + */ + private async builtTrainWagonCount( + schedule: TrainSchedule, + ): Promise { + const trainId = schedule.trainSet?.train?.id; + if (!trainId) return null; + const count = await this.dataSource + .getRepository(Wagon) + .count({ where: { trainId } }); + return count > 0 ? count : null; + } + /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide @@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / - * train length for even one more loaded wagon. The old slot-only check let - * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of - * 3500+90T, slots bind at 44) cycle its booking window forever instead of - * finalizing — 7 phantom slots kept it "not full" while nothing could board. + * Built train: FULL when every physical wagon slot is taken — the consist is + * the capacity, weight/length were settled at build time. + * No built train: FULL on ANY capacity axis — out of wagon slots, or out of + * pull weight / train length for even one more loaded wagon. The old + * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = + * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever + * instead of finalizing — 7 phantom slots kept it "not full" while nothing + * could board. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit { return this.isTrainFull(schedule); } + /** + * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the + * schedule's slot capacity, how many slots allocated + reserved bookings + * already hold on the busiest edge, how many are still free on the most-open + * edge, and by how many slots the consist has been trimmed BELOW what is + * already committed (0 when nothing is over-allocated). + */ + async scheduleWagonUsage(scheduleId: string): Promise<{ + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return null; + const capacity = + (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; + const wagonDims = await this.loadWagonDims(); + const budget = await this.remainingBudget( + schedule, + { + base: { + wagons: capacity, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }, + wagonDims, + ); + const tightest = budget.remainingFor(budget.fullLeg()).wagons; + return { + maxWagons: capacity, + allocatedWagons: capacity - tightest, + remainingSlots: Math.max(0, budget.maxRemaining().wagons), + overAllocatedBy: Math.max(0, -tightest), + }; + } + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { if ((await this.remainingWagons(schedule)) <= 0) return true; + // Built train: the physical consist is the only capacity axis. Weight and + // length were enforced when the consist was assembled (builder / + // adjust-consist), so a free wagon slot means the train genuinely has room. + if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 426bd37be..6f366d060 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -9,6 +9,8 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { FacilityHandlingService } from './facility-handling.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -43,6 +45,8 @@ export class BookingJourneyService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly yardFacilities: YardFacilitiesService, + private readonly facilityHandling: FacilityHandlingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -63,6 +67,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -72,6 +77,16 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // The facility handed the cargo over — raise its GRN. No-ops for yards + // without a facility (import/export terminals), which keep their own flow. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.originYardId, + trainScheduleId: scheduleId, + eventType: 'LOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: cargo is on the train — loading milestones plus the @@ -99,6 +114,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ // export continue into clearance, keyed on the booking's own arrival. @@ -112,6 +128,17 @@ export class BookingJourneyService { } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); + // The facility took the cargo off the train — raise its GRN. Where the + // facility also stores cargo (Indode), the event links the storage record + // that storage/demurrage accrue against. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.destinationYardId, + trainScheduleId: scheduleId, + eventType: 'UNLOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: THIS booking arrived (train may still be rolling). @@ -306,6 +333,33 @@ export class BookingJourneyService { }); } + /** + * INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the + * booking's own yards, so those yards need the equipment to do it — a train + * stopping somewhere is not the same as somewhere being able to load it. + * + * Import/export are untouched: their cargo is handled at the route's terminal + * ports, not at an arbitrary mid-corridor yard, and gating them here would + * block existing traffic. + * + * Lives here rather than in the controller so the checkpoint-driven + * autoUnloadAtYard path cannot route around it. + */ + private async assertYardCanHandleCargo( + booking: Booking, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + if (booking.tradeDirection !== 'DOMESTIC') return; + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) { + throw new BadRequestException( + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + + `${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2948b874d..c5e6fa65f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -3,20 +3,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { - @ApiPropertyOptional({ example: 760 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainLengthMeters?: number; - - @ApiPropertyOptional({ example: 3500 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainWeightTons?: number; - @ApiPropertyOptional({ example: 53 }) @IsOptional() @Type(() => Number) @@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ example: 30 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0.001) - max20ftContainerWeightTons?: number; - - @ApiPropertyOptional({ example: 10 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0) - max20ftPairWeightDiffTons?: number; - @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) @IsOptional() @Type(() => Number) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts new file mode 100644 index 000000000..cd8807b8a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const FACILITY_HANDLING_EVENT_TYPES = ['LOAD', 'UNLOAD'] as const; +export type FacilityHandlingEventType = (typeof FACILITY_HANDLING_EVENT_TYPES)[number]; + +/** + * Cargo loaded onto or unloaded off a train at a yard's facility, and the GRN + * raised for it. + * + * This exists because warehouse_inventory can't do the job: its + * warehouse/yard/zone are NOT NULL, so a facility that only has equipment and no + * warehouse (Sebeta, Modjo, Adama, Dire Dawa) could never have a row there — + * yet it still hands cargo over and still needs a GRN. + * + * `inventoryId` links to the warehouse record when the facility does store cargo + * (Indode), which is what makes storage and demurrage accrue there and nowhere + * else. + */ +@Entity({ schema: 'freight', name: 'facility_handling_events' }) +@Index(['bookingId']) +@Index(['yardId']) +export class FacilityHandlingEvent extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + /** The facility yard where the cargo was handled. */ + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + /** The train the cargo came off / went onto. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 10 }) + eventType!: FacilityHandlingEventType; + + @Column({ name: 'grn_number', type: 'varchar', length: 60, nullable: true }) + grnNumber?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 14, scale: 3, nullable: true }) + quantity?: number | null; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightTons?: number | null; + + /** Set only when the facility stores cargo (has_warehouse) — the storage record. */ + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts new file mode 100644 index 000000000..c0e0b7c5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { generateGrnNumber } from '../../common/grn.util'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { + FacilityHandlingEvent, + FacilityHandlingEventType, +} from './entities/facility-handling-event.entity'; + +/** + * Records cargo being loaded/unloaded at a yard's facility, and raises its GRN. + * + * Every facility raises a GRN — the goods changed hands, whether or not anyone + * stores them. What differs is what happens next: a facility with a warehouse + * (Indode) keeps the cargo, so it goes through the normal warehouse flow and + * accrues storage/demurrage; the rest only move it between train and truck, so + * the event and its GRN are the whole record. + * + * Best-effort by design: a failure here must not undo a load/unload that + * physically happened. + */ +@Injectable() +export class FacilityHandlingService { + private readonly logger = new Logger(FacilityHandlingService.name); + + constructor(private readonly yardFacilities: YardFacilitiesService) {} + + /** + * Write the handling event and mint its GRN. Returns the GRN, or null when the + * yard has no facility (nothing to record) or the write failed. + */ + async recordHandling( + manager: EntityManager, + input: { + booking: Booking; + yardId: string; + trainScheduleId?: string | null; + eventType: FacilityHandlingEventType; + performedBy?: string | null; + occurredAt?: Date; + }, + ): Promise { + const { booking, yardId, eventType } = input; + try { + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) return null; + + const occurredAt = input.occurredAt ?? new Date(); + const grnNumber = generateGrnNumber( + booking.tradeDirection ?? 'DOMESTIC', + booking.id, + occurredAt, + ); + + // Link the storage record when this facility keeps cargo — that link is + // what ties an Indode handover to its storage/demurrage. + let inventoryId: string | null = null; + if (facility.hasWarehouse) { + const [inv]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [booking.id], + ); + inventoryId = inv?.id ?? null; + } + + const repo = manager.getRepository(FacilityHandlingEvent); + await repo.save( + repo.create({ + bookingId: booking.id, + yardId, + trainScheduleId: input.trainScheduleId ?? null, + eventType, + grnNumber, + weightTons: Number(booking.cargoTotalWeightVgm) || null, + inventoryId, + performedBy: input.performedBy ?? null, + occurredAt, + }), + ); + + this.logger.log( + `GRN ${grnNumber} raised on ${eventType} at ${facility.yardCode ?? yardId} for booking ${booking.reference ?? booking.id}`, + ); + return grnNumber; + } catch (err) { + // The cargo moved regardless — never fail the journey over the paperwork. + this.logger.error( + `Facility ${eventType} record failed for booking ${booking.id} at yard ${yardId}: ${String(err)}`, + ); + return null; + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 9cca4d213..4d408689b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n } // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 - // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // wagon). Derived from containerType.sizeFt; falls back to the line's stored // fraction. Ceiling per line would over-count split 20ft lines. return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 1e1eb1695..fa8623b4a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { FacilityHandlingService } from './facility-handling.service'; +import { FacilityHandlingEvent } from './entities/facility-handling-event.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Route } from '../routes/entities/route.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; @@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ TypeOrmModule.forFeature([ + FacilityHandlingEvent, Locomotive, WagonType, TrainSet, @@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingSplitService, IntercityService, BookingJourneyService, + FacilityHandlingService, ], exports: [ TrainSchedulingService, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index bb85b4422..0d9bf63c9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -96,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingBatchService } from './booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -318,6 +321,11 @@ export class TrainSchedulingService { private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, + // forwardRef: BookingBatchService injects this service back; @Optional so + // existing specs that construct the service without it keep working. + @Optional() + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService?: BookingBatchService, ) {} /** @@ -592,7 +600,24 @@ export class TrainSchedulingService { } async getTrainSchedulingGlobalRules() { - return this.loadGlobalRulesRow(); + return this.toPublicGlobalRules(await this.loadGlobalRulesRow()); + } + + /** + * Train length/weight and 20ft weight caps are engine-internal (wagon + * planning still reads them off the row); they are no longer exposed or + * editable through the global-rules endpoints. + */ + private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { + if (!row) return row; + const { + maxTrainLengthMeters: _len, + maxTrainWeightTons: _wt, + max20ftContainerWeightTons: _cw, + max20ftPairWeightDiffTons: _pd, + ...pub + } = row; + return pub; } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { @@ -600,15 +625,7 @@ export class TrainSchedulingService { if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } - if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; - if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; - if (dto.max20ftContainerWeightTons != null) { - row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; - } - if (dto.max20ftPairWeightDiffTons != null) { - row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; - } if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; @@ -646,7 +663,7 @@ export class TrainSchedulingService { await this.restampPendingWindows(); } - return saved; + return this.toPublicGlobalRules(saved); } /** @@ -5109,6 +5126,11 @@ export class TrainSchedulingService { wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), ); + // Wagon-slot picture for the dialog: the consist IS the schedule's booking + // capacity, so trimming/coupling wagons moves the FULL line live. + const wagonUsage = + (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null; + const mapWagon = (wagon: Wagon) => ({ id: wagon.id, wagonNumber: wagon.wagonNumber, @@ -5147,6 +5169,12 @@ export class TrainSchedulingService { grossTons: roundTons(cargoTons + consistTareTons), consistLengthMeters, }, + scheduleCapacity: wagonUsage + ? { + ...wagonUsage, + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + } + : null, wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), @@ -5339,7 +5367,37 @@ export class TrainSchedulingService { ); }); - return this.getScheduleConsist(scheduleId); + // The consist IS the schedule's booking capacity, so an edit moves the + // FULL line: freeing slots on a FULL schedule reopens its window, taking + // the last slot closes it. Staff may shrink below what is already + // committed — allowed, but reported back as a warning (never silently). + const warnings: string[] = []; + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId); + if (usage) { + const nowFull = usage.remainingSlots <= 0; + if (usage.overAllocatedBy > 0) { + warnings.push( + `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` + + `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` + + 'Couple more wagons or free bookings before departure.', + ); + } + if (wasFull && !nowFull) { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + warnings.push( + `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` + + 'so it is no longer FULL and can take bookings again.', + ); + } else if (!wasFull && nowFull) { + await this.bookingBatchService?.setWindow(scheduleId, 'FULL'); + warnings.push( + 'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.', + ); + } + } + + return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 157666f55..bb9adf319 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => { fortyFooter.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40, - wagonsPerUnit: 1, } as never; const result = planWagonsWithStock({ bookings: [fortyFooter], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 19e19dca3..c3d48f286 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -106,7 +106,7 @@ describe('wagon-plan.util', () => { }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { - // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); @@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, - containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { @@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); - it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 78cd8bf54..bb5ce890c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -1,6 +1,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { consistViolations } from './train-capacity.util'; @@ -61,7 +62,6 @@ export type ContainerUnitRow = { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; @@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; } -export function containersPerWagonFromType(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; - containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; + containerType?: { sizeFt?: number | null } | null; }; /** - * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit - * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so - * the BOOKING total is ceiled once — ceiling per line over-counts a booking that - * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived + * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept + * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a + * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3 + * wagons, not 4). */ function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; - const wpu = Number(line.containerType?.wagonsPerUnit); - if (Number.isFinite(wpu) && wpu > 0) { - return qty * wpu; + const sizeFt = Number(line.containerType?.sizeFt); + if (Number.isFinite(sizeFt) && sizeFt > 0) { + return qty * wagonsPerUnitForSize(sizeFt); } - // No wagonsPerUnit on the type: fall back to the line's stored fraction, else - // treat the whole line as one wagon. + // No size on the type: fall back to the line's stored fraction, else treat + // the whole line as one wagon. const stored = Number(line.wagonsRequired); return Number.isFinite(stored) && stored > 0 ? stored : 1; } @@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const qty = Number(line.quantity ?? 0); const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); - const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); - const perWagon = containersPerWagonFromType(wagonsPerUnit); + const perWagon = containersPerWagonForSize(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt); // The REAL per-container numbers/weights entered at booking time. Unit i of // the line maps to units[i] (sortOrder order); the line-level number is only @@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, - wagonsPerUnit, containersPerWagon: perWagon, teuSlots, containerNumber: diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index d1939c9f5..408b13be5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -20,6 +20,16 @@ export class CreateWagonDto { // Tare weight and payload capacity are not accepted here: they belong to the // wagon type and are resolved through wagonTypeId. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */ + @IsOptional() + @IsString() + exportTrainNumber?: string; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */ + @IsOptional() + @IsString() + importTrainNumber?: string; + @IsOptional() @IsEnum(WagonStatus) status?: WagonStatus; diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index b66fc3f9d..7bfb59e1d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -43,6 +43,14 @@ export class Wagon extends BaseEntity { // Tare weight and payload capacity are properties of the wagon TYPE — read them // through `wagonType`, never off the individual wagon. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 8c5dd83c8..a43ff0e67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -38,6 +38,8 @@ export class WagonsService { if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; + if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; + if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c489b6f89..1549caf0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { generateGrnNumber } from '../../common/grn.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; @@ -5073,10 +5074,9 @@ export class WarehouseInventoryService { } } + /** Shared with the facility handling flow — see common/grn.util.ts. */ private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); - const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + return generateGrnNumber(direction, referenceId, date); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index 716532165..eb39bbc87 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,7 @@ import { AppDataSource } from '../data-source'; import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; +import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers'; +import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,7 +12,12 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); + // Fleet first (recreates every wagon with NULL runs), then the columns are + // ensured to exist, then the run roster is applied on top. Same order the + // migrations run in, so the script and a fresh migrate agree. await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); + await new AddWagonTrainNumbers2270000000000().up(queryRunner); + await new SeedWagonRunNumbers2280000000000().up(queryRunner); const summary = await queryRunner.query(` SELECT @@ -20,7 +27,8 @@ async function seedEdRWagons() { MIN(w.wagon_number) AS first_wagon, MAX(w.wagon_number) AS last_wagon, COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, - COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard, + COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' @@ -29,13 +37,33 @@ async function seedEdRWagons() { `); const [totals] = await queryRunner.query(` - SELECT COUNT(*)::int AS total FROM freight.wagons; + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons; + `); + + const runs = await queryRunner.query(` + SELECT + export_train_number AS export_run, + import_train_number AS import_run, + COUNT(*)::int AS wagons + FROM freight.wagons + WHERE export_train_number IS NOT NULL + GROUP BY export_train_number, import_train_number + ORDER BY export_train_number; `); await queryRunner.commitTransaction(); + console.log('\nFleet by wagon type:'); console.table(summary); - console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); + console.log('Run roster (export/import pairs):'); + console.table(runs); + console.log( + `Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` + + `${totals.on_a_run} on a run (expected 533).`, + ); } catch (error) { await queryRunner.rollbackTransaction(); throw error; diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a8abee67b..dbfd5cee6 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -209,7 +209,6 @@ async function ensureReferences(manager: any) { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 4f801330a..9cf1ef0cd 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -118,7 +118,6 @@ async function main() { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts index 367b79b44..b14ac8c15 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -115,7 +116,7 @@ async function main() { reeferQuantity: 0, vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), totalVgmTons: Number((weightKg / 1000).toFixed(3)), - wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)), isOverweight: false, }), ); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 989e18bf1..2e234bcf0 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -11,6 +11,7 @@ import { } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { } const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 3720c0e2a..20d2a3f8b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { Container } from "../modules/container-management/entities/container.entity"; import { Route } from "../modules/routes/entities/route.entity"; @@ -300,7 +301,6 @@ export class DemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -400,7 +400,7 @@ export class DemoBookingsSeeder { .getRepository(BookingContainer) .delete({ bookingId: booking.id }); const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6e4ec81b1..d3b5bbb00 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record> = { + rates: 'b2000001-0001-4000-8000-000000000017', +}; + +export type RuleEngineApprovableSlug = 'rates'; + export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ...(approveId + ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + : []), ]; }, ); @@ -389,6 +404,8 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + approve: (slug: RuleEngineApprovableSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { manage: 'edr_freight_app:allocation:manage', diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index e1c46d168..f60d7bb75 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder { const isImport = demoBooking.tradeDirection === 'IMPORT'; const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 872e909bb..147f2ae54 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,6 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -125,7 +124,6 @@ export class PricingDataSeeder { code: "40FT", label: "40FT Standard", sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +133,6 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -145,7 +142,6 @@ export class PricingDataSeeder { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, - wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts new file mode 100644 index 000000000..47754bd93 --- /dev/null +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * EDR's load/unload facilities, mapped onto the yards that already represent them. + * + * The codes are historical and don't read like the facility names, so map by code + * and never by label: Indode is `KALITY` ("Gelan Multi Purpose Port (Indode)") and + * Sebeta is `LEGACY_DEST` ("Sebeta"). Creating fresh INDODE/SEBETA yards would + * split data that existing routes and bookings already point at. + * + * Only Indode stores cargo, so it is the only facility with a warehouse — the rest + * move cargo on and off the train, which is why they accrue no storage/demurrage. + * + * Negad is deliberately absent: there are two candidates (`NAGAD` "DCT/SGDT" in + * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet + * settled which is the intercity facility. + */ +const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ + { code: 'KALITY', facility: 'Indode', hasWarehouse: true }, + { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, + { code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, + { code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, + { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, +]; + +@Injectable() +export class YardFacilitiesSeeder { + private readonly logger = new Logger(YardFacilitiesSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + /** + * Idempotent: flags existing yards and upserts their facility record. Creates no + * yards — a missing code is logged and skipped rather than invented. + */ + async run(): Promise { + for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { + const [yard]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (!yard) { + this.logger.warn(`Yard ${code} (${facility}) not found — skipping facility flag`); + continue; + } + + await this.dataSource.query( + `UPDATE freight.yards + SET has_facility = true, updated_at = NOW() + WHERE id = $1 AND has_facility = false`, + [yard.id], + ); + + await this.dataSource.query( + `INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) + VALUES ($1, $2, $3) + ON CONFLICT (yard_id) WHERE deleted_at IS NULL + DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, + [yard.id, hasWarehouse, `${facility} load/unload facility`], + ); + } + this.logger.log( + `Yard facilities seeded: ${FACILITY_YARDS.map((f) => f.facility).join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..471849c95 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -18,6 +18,7 @@ import { Send, Settings, ShieldCheck, + Settings2, Ship, SlidersHorizontal, Train, @@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +599,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +677,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 7262e7927..8686f9cdf 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -263,6 +263,14 @@ const FleetFormDialog = ({ return map; }, [fields]); + // Emptying one of these means "unset the column", so it submits an explicit + // null instead of being dropped from the payload like other empty fields. + const clearableByName = useMemo(() => { + const map: Record = {}; + fields.forEach((f) => (map[f.name] = Boolean(f.clearable))); + return map; + }, [fields]); + const handleSubmit = () => { // Hard gate: a driver record cannot be saved until its identity is verified // with Fayda. Mirrored server-side in DriversService. @@ -271,11 +279,17 @@ const FleetFormDialog = ({ return; } if (!validate()) return; + // Derived fields are never edited, so form state for them can be stale (or + // seeded from the record) — recompute before building the payload. + const submitted: Record = { ...values }; + fields.forEach((field) => { + if (field.derivedValue) submitted[field.name] = field.derivedValue(values); + }); const payload = Object.fromEntries( - Object.entries(values) + Object.entries(submitted) .map(([key, value]) => { - if (value === FLEET_SELECT_NONE || value === "") - return [key, undefined]; + if (value === FLEET_SELECT_NONE || value === "" || value == null) + return [key, clearableByName[key] ? null : undefined]; if (fieldTypeByName[key] === "number") { const num = Number(value); return [key, Number.isNaN(num) ? undefined : num]; @@ -294,6 +308,23 @@ const FleetFormDialog = ({ // only by verification and never hand-edited. const isDisabled = Boolean(field.disabled || field.faydaLocked); + // Computed from other fields (e.g. the import run implied by the export + // run) — read-only, and recomputed here rather than read from form state. + if (field.derivedValue) { + return ( + + ); + } + if (field.type === "radio") { return ( walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index c121acd36..fc33ae45a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -1,5 +1,6 @@ import { Freight } from "@edr/types"; import { + Badge, Button, Checkbox, Group, @@ -24,11 +25,19 @@ export default function AvailableWagonsPanel({ yardLabel, onAssign, assigning, + exportTrainNumber, + importTrainNumber, }: AvailableWagonsPanelProps) { const [search, setSearch] = useState(""); const [typeFilter, setTypeFilter] = useState("ALL"); + const [runOnly, setRunOnly] = useState(false); const [selected, setSelected] = useState([]); + // The train's own run, e.g. "8001-8002" — only offered when the train has one. + const runLabel = exportTrainNumber + ? `${exportTrainNumber}${importTrainNumber ? `-${importTrainNumber}` : ""}` + : null; + const wagonsQuery = useQuery( api.wagons.list.queryOptions({ input: { @@ -42,10 +51,23 @@ export default function AvailableWagonsPanel({ const q = search.trim().toLowerCase(); return (wagonsQuery.data ?? []).filter((wagon) => { if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; + // Rostered to this train's run — match on the export run, which fixes the + // import run anyway. + if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false; if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; return true; }); - }, [wagonsQuery.data, search, typeFilter]); + }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]); + + const runMatchCount = useMemo( + () => + exportTrainNumber + ? (wagonsQuery.data ?? []).filter( + (w) => w.exportTrainNumber === exportTrainNumber, + ).length + : 0, + [wagonsQuery.data, exportTrainNumber], + ); const typeOptions = useMemo(() => { const byId = new Map(); @@ -112,6 +134,15 @@ export default function AvailableWagonsPanel({ /> + {runLabel ? ( + setRunOnly(e.currentTarget.checked)} + /> + ) : null} + {wagons.length ? ( - - {wagon.wagonNumber} - + + + {wagon.wagonNumber} + + {wagon.exportTrainNumber ? ( + + {wagon.exportTrainNumber} + {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""} + + ) : null} + {wagon.wagonType ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap` @@ -183,4 +229,8 @@ export interface AvailableWagonsPanelProps { yardLabel?: string | null; onAssign: (wagonIds: string[]) => void; assigning: boolean; + /** This train's odd EXPORT run — drives the "only this run" filter. */ + exportTrainNumber?: string | null; + /** This train's even IMPORT run — label only; the export run does the matching. */ + importTrainNumber?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 7f60cabe9..49dd19bcc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -16,6 +16,7 @@ import { useEffect, useState } from "react"; import { api } from "@/services/api"; import type { TrainComposition } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; +import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { @@ -26,10 +27,6 @@ const parseError = (error: unknown, fallback: string) => { return fallback; }; -// Run-number parity carries the trade direction: odd = export, even = import. -const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim()); -const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim()); - /** * Step one of the Train Builder: pick the yard it is being assembled in and * couple at least two locomotives from that yard. The train code is assigned by @@ -59,6 +56,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain setLocomotiveIds([]); }, [yardId]); + // The import run is fixed by the export run, so it tracks it rather than + // being entered by hand (and clears back to empty when the export is cleared). + useEffect(() => { + setImportTrainNumber(importRunFor(exportTrainNumber)); + }, [exportTrainNumber]); + useEffect(() => { if (!opened) { setExportTrainNumber(""); @@ -78,9 +81,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain }); return; } - if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) { + // Both numbers come from the fixed run pairs, so parity cannot be wrong — + // only "nothing picked" is reachable here. + if (!exportTrainNumber || !importTrainNumber) { toast({ - title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)", + title: "Pick an export train number (e.g. 8001) — the import run follows it", variant: "destructive", }); return; @@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain maxLength={100} /> - setExportTrainNumber(e.currentTarget.value)} - maxLength={20} - error={ - exportTrainNumber && !isOddNumber(exportTrainNumber) - ? "Must be numeric and odd" - : undefined - } + data={EXPORT_TRAIN_OPTIONS} + value={exportTrainNumber || null} + onChange={(value) => setExportTrainNumber(value ?? "")} + searchable + clearable /> + {/* Fixed by the export run — derived, never typed. */} setImportTrainNumber(e.currentTarget.value)} - maxLength={20} - error={ - importTrainNumber && !isEvenNumber(importTrainNumber) - ? "Must be numeric and even" - : undefined - } + readOnly + variant="filled" />