diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index bca74475e..66909a037 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -14,6 +14,7 @@
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
+ "seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 72a5952d7..5a9ae9ef9 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
+import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
@@ -59,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";
@@ -159,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware";
BillingModule,
NotificationsModule,
NotificationInboxModule,
+ SupportChatModule,
FileUploadSettingsModule,
DropdownSettingsModule,
ContractTemplatesModule,
@@ -196,6 +199,7 @@ import { LoggerMiddleware } from "./logger.middleware";
EdrOrgSeeder,
FreightPositionsSeeder,
FileUploadSettingsSeeder,
+ YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
@@ -221,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,
@@ -258,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/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts
new file mode 100644
index 000000000..177b8549b
--- /dev/null
+++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts
@@ -0,0 +1,28 @@
+/**
+ * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
+ * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
+ *
+ * A booking reaches a train through WAGON ALLOCATION
+ * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
+ * which is what the allocation UI writes. `train_schedule_bookings` is only ever
+ * written by the demo seeders, so both sources are unioned: real allocations work
+ * and the seeded scenarios keep working.
+ *
+ * Shared so the warehouse loading queue and the train dispatch guard agree on
+ * exactly which bookings are on a train — if they drift, a train can be
+ * dispatched leaving cargo the warehouse still thinks it should load.
+ */
+export const SCHEDULE_BOOKINGS_CTE = `
+ sched_bookings AS (
+ SELECT ts.id AS schedule_id, wba.booking_id
+ FROM freight.train_schedules ts
+ JOIN freight.train_set_wagons tsw
+ ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
+ JOIN freight.wagon_booking_allocations wba
+ ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
+ WHERE ts.deleted_at IS NULL
+ UNION
+ SELECT tsb.train_schedule_id, tsb.booking_id
+ FROM freight.train_schedule_bookings tsb
+ WHERE tsb.deleted_at IS NULL
+ )`;
diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
index 717a48217..a651b8f60 100644
--- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
+++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
@@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
- // Wagon.wagonNumber declares `unique: true`, but some environments never got
- // the constraint. Repair it here — the table is empty at this point, so the
- // index build cannot fail on pre-existing duplicates.
- await queryRunner.query(`
- CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
- ON freight.wagons (wagon_number);
- `);
+ // Deliberately does NOT create a unique index on wagon_number. It once did,
+ // to satisfy an ON CONFLICT clause that no longer exists (the DELETE above
+ // makes collisions impossible). Recreating the plain index here would undo
+ // WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL
+ // unique index so soft-deleted wagons stop reserving their number — this
+ // seeder is run directly by scripts/seed-edr-wagons.ts, which would
+ // otherwise resurrect the plain index on an already-migrated database.
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {
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-SeedWagonYardDoraleh.ts b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts
new file mode 100644
index 000000000..7e12efdbb
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2290000000000-SeedWagonYardDoraleh.ts
@@ -0,0 +1,68 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Stand the whole wagon fleet in Doraleh.
+ *
+ * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
+ * wagon with a NULL yard — so this must stay later in timestamp order.
+ *
+ * A wagon with no yard cannot be coupled to a train (the train builder only
+ * offers AVAILABLE wagons standing in the train's own yard), which left the
+ * seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs
+ * originate from.
+ *
+ * The yard is created when absent: environments disagree about which yards
+ * exist, so this cannot assume one is there.
+ */
+const YARD_CODE = 'DORALEH';
+
+export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface {
+ name = 'SeedWagonYardDoraleh2290000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // Ensure the yard exists and is usable. Deliberately does NOT overwrite an
+ // existing label/country — a deployment that already calls this yard
+ // something else keeps its own naming.
+ await queryRunner.query(
+ `
+ INSERT INTO freight.yards (code, label, country, is_active, display_order)
+ VALUES ($1, 'Doraleh', 'Djibouti', true, 12)
+ ON CONFLICT (code) DO UPDATE SET
+ is_active = true,
+ deleted_at = NULL,
+ updated_at = now();
+ `,
+ [YARD_CODE],
+ );
+
+ const [yard] = await queryRunner.query(
+ `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
+ [YARD_CODE],
+ );
+
+ if (!yard?.id) {
+ throw new Error(`yard_missing:${YARD_CODE}`);
+ }
+
+ // Whole fleet — a wagon already coupled to a built train follows the train,
+ // so leave those where they stand.
+ await queryRunner.query(
+ `
+ UPDATE freight.wagons
+ SET current_yard_id = $1::uuid,
+ updated_at = now()
+ WHERE train_id IS NULL;
+ `,
+ [yard.id],
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // Back to the state SeedEdrWagonFleetErNumbering leaves them in.
+ await queryRunner.query(`
+ UPDATE freight.wagons
+ SET current_yard_id = NULL
+ WHERE train_id IS NULL;
+ `);
+ }
+}
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/migrations/2310000000000-CreateSupportChat.ts b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts
new file mode 100644
index 000000000..17db8a4e1
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts
@@ -0,0 +1,78 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Customer-support chat. A `support_conversations` row is the single ongoing
+ * thread with a company; `support_messages` are its text messages. There is no
+ * lifecycle column — a thread is opened by whichever side speaks first and
+ * stays open. Enum-like columns are varchar (no PG enum churn).
+ *
+ * The unique index on `company_id` is load-bearing, not just an optimization:
+ * the get-or-create path depends on it to settle concurrent first-messages.
+ * It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
+ * a fresh one.
+ */
+export class CreateSupportChat2310000000000 implements MigrationInterface {
+ name = "CreateSupportChat2310000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.support_conversations (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id uuid NOT NULL,
+ company_name varchar(200),
+ created_by_user_id uuid,
+ last_message_at timestamptz,
+ last_message_preview varchar(280),
+ last_message_author_role varchar(12),
+ customer_last_read_at timestamptz,
+ agent_last_read_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz
+ )
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
+ ON freight.support_conversations (company_id)
+ WHERE deleted_at IS NULL
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
+ ON freight.support_conversations (last_message_at)
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.support_messages (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ conversation_id uuid NOT NULL,
+ author_user_id uuid NOT NULL,
+ author_role varchar(12) NOT NULL,
+ author_name varchar(200),
+ body text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz
+ )
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
+ ON freight.support_messages (conversation_id, created_at)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
+ );
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
+ );
+ await queryRunner.query(
+ `DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
+ );
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.support_conversations`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts
new file mode 100644
index 000000000..8e693cf2a
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts
@@ -0,0 +1,140 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Scope base rail freight to a route (origin yard → destination yard).
+ *
+ * Until now a base-freight rate was keyed by direction + container/bulk scope
+ * only, so "container import" cost the same whether the box was railed to Dire
+ * Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which
+ * is what the business actually sells: `container import, Djibouti → Dire Dawa,
+ * 500 USD`.
+ *
+ * Existing base-freight rates predate the yard pair and cannot be backfilled —
+ * there is no way to know which route each was meant for. They are retired
+ * (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot
+ * and rate_change_requests hold FKs to them (RESTRICT) and those rows are price
+ * history. Retiring drops them out of pricing and the admin UI just the same;
+ * the yard-scoped replacements must be re-entered.
+ *
+ * Surcharges, first-mile and last-mile rates are untouched: they are not
+ * route-scoped and keep NULL yards.
+ */
+export class AddRateYardScope2320000000000 implements MigrationInterface {
+ name = 'AddRateYardScope2320000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // ── 1. Yard columns + FKs ──────────────────────────────────────────────
+ await queryRunner.query(`
+ ALTER TABLE freight.rates
+ ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL,
+ ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL;
+ `);
+
+ await queryRunner.query(`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN
+ ALTER TABLE freight.rates
+ ADD CONSTRAINT "FK_rates_origin_yard_id"
+ FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id);
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN
+ ALTER TABLE freight.rates
+ ADD CONSTRAINT "FK_rates_destination_yard_id"
+ FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id);
+ END IF;
+ END $$;
+ `);
+
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`,
+ );
+ await queryRunner.query(
+ `CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`,
+ );
+
+ // ── 2. Retire route-less base freight ──────────────────────────────────
+ // Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE
+ // RESTRICT and those snapshots are what past bookings were charged.
+ await queryRunner.query(`
+ UPDATE freight.rates
+ SET status = 'SUPERSEDED',
+ deleted_at = now(),
+ updated_at = now()
+ WHERE deleted_at IS NULL
+ AND "trigger" = 'ALWAYS'
+ AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY');
+ `);
+
+ // ── 3. Route is part of a rate's identity ──────────────────────────────
+ // Two rates may now share rateType + scope + unit as long as they price
+ // different legs, so the yard pair joins the uniqueness tuple.
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
+ ON freight.rates (
+ rate_type,
+ COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ COALESCE(trade_direction, ''),
+ COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ rate_unit
+ )
+ WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
+ `);
+
+ // ── 4. Base freight must carry a route; nothing else may ───────────────
+ // Retired rows are exempt — they are the route-less rates step 2 just
+ // superseded, and they must stay readable for snapshot history.
+ await queryRunner.query(`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN
+ ALTER TABLE freight.rates
+ ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
+ deleted_at IS NOT NULL
+ OR status = 'SUPERSEDED'
+ OR CASE
+ WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
+ THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
+ ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
+ END
+ );
+ END IF;
+ END $$;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ // The retired rates are not un-superseded: which route each belonged to was
+ // never recorded, so reviving them would restore rates that price the wrong
+ // legs. Down only reverses the schema.
+ await queryRunner.query(
+ `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
+ );
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
+ ON freight.rates (
+ rate_type,
+ COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
+ COALESCE(trade_direction, ''),
+ rate_unit
+ )
+ WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
+ `);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`);
+ await queryRunner.query(
+ `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`,
+ );
+ await queryRunner.query(`
+ ALTER TABLE freight.rates
+ DROP COLUMN IF EXISTS destination_yard_id,
+ DROP COLUMN IF EXISTS origin_yard_id;
+ `);
+ }
+}
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..d3fe02d5f 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,
};
}),
@@ -477,7 +475,14 @@ export class BookingPricingService {
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
- const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
+ const rate = this.pickRate(
+ liveRates,
+ rateType,
+ container.containerTypeId,
+ 'USD',
+ booking.originYardId,
+ booking.destinationYardId,
+ );
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
@@ -517,8 +522,15 @@ export class BookingPricingService {
}
if (lines.length === 0) {
+ // Bulk (and any booking with no container lines) still has to price off a
+ // rate configured for this leg — never one belonging to another route.
const fallback = liveRates.find(
- (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
+ (r) =>
+ r.rateType === rateType &&
+ r.currency === 'USD' &&
+ r.status === 'LIVE' &&
+ r.originYardId === booking.originYardId &&
+ r.destinationYardId === booking.destinationYardId,
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
@@ -713,20 +725,32 @@ export class BookingPricingService {
}
}
+ /**
+ * Base freight is quoted per leg, so a rate only applies to a booking running
+ * the exact origin → destination it was configured for. There is deliberately
+ * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
+ * because nobody configured Mojo yet is worse than surfacing no line at all.
+ * Within the leg, a rate scoped to the container type wins over one that
+ * covers every type.
+ */
private pickRate(
rates: Rate[],
rateType: string,
containerTypeId: string,
currency: string,
+ originYardId: string,
+ destinationYardId: string,
): Rate | undefined {
+ const onLeg = rates.filter(
+ (r) =>
+ r.rateType === rateType &&
+ r.currency === currency &&
+ r.originYardId === originYardId &&
+ r.destinationYardId === destinationYardId,
+ );
return (
- rates.find(
- (r) =>
- r.rateType === rateType &&
- r.currency === currency &&
- r.containerTypeId === containerTypeId,
- ) ??
- rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
+ onLeg.find((r) => r.containerTypeId === containerTypeId) ??
+ onLeg.find((r) => !r.containerTypeId)
);
}
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/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
index d30ebe3a9..e9f3af958 100644
--- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
+++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts
@@ -33,6 +33,7 @@ import { WsAuthService } from "./ws-auth.service";
WsAuthService,
NotificationInboxService,
],
- exports: [NotificationInboxService],
+ // WsAuthService is reused by the support-chat gateway for handshake auth.
+ exports: [NotificationInboxService, WsAuthService],
})
export class NotificationInboxModule {}
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-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
index 9a4cd642b..5507692d9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
@@ -9,6 +9,7 @@ import {
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const;
+export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
export class CreateRateDto {
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
@@ -37,6 +38,31 @@ export class CreateRateDto {
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
+ @ApiPropertyOptional({
+ enum: INTERCITY_KINDS,
+ description:
+ 'Whether an intercity rate covers containers or bulk. Required when appliesTo = INTERCITY; ignored otherwise. Not stored — it selects the INTERCITY_CONTAINER / INTERCITY_BULK rate type.',
+ })
+ @IsOptional()
+ @IsIn([...INTERCITY_KINDS])
+ intercityKind?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
+ })
+ @IsOptional()
+ @IsUUID()
+ originYardId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
+ })
+ @IsOptional()
+ @IsUUID()
+ destinationYardId?: string;
+
@ApiPropertyOptional({ enum: CURRENCIES })
@IsOptional()
@IsIn([...CURRENCIES])
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/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
index 83c225ea1..6f7ac78ee 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { CargoType } from './cargo-type.entity';
import { ContainerType } from './container-type.entity';
+import { Yard } from './yard.entity';
export const RATE_TYPES = [
'CONTAINER_IMPORT',
@@ -91,6 +92,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
@Index(['status'])
@Index(['containerTypeId'])
@Index(['trigger'])
+@Index(['originYardId'])
+@Index(['destinationYardId'])
export class Rate extends BaseEntity {
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: RateType;
@@ -118,6 +121,26 @@ export class Rate extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
tradeDirection?: string | null;
+ /**
+ * The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
+ * route — "container import, Djibouti → Dire Dawa" — so both yards are
+ * required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
+ * `CK_rates_yard_scope` DB constraint enforces both halves of that.
+ */
+ @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
+ originYardId?: string | null;
+
+ @ManyToOne(() => Yard, { nullable: true, eager: false })
+ @JoinColumn({ name: 'origin_yard_id' })
+ originYard?: Yard | null;
+
+ @Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
+ destinationYardId?: string | null;
+
+ @ManyToOne(() => Yard, { nullable: true, eager: false })
+ @JoinColumn({ name: 'destination_yard_id' })
+ destinationYard?: Yard | null;
+
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;
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/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
index be0962942..a23e47eea 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts
@@ -12,6 +12,8 @@ export interface IRatesRepository {
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
+ originYardId?: string | null;
+ destinationYardId?: string | null;
}): Promise;
findAll(options?: FindManyOptions): Promise;
findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
index 85bfc783f..d855fb491 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts
@@ -37,6 +37,8 @@ export class RatesRepository implements IRatesRepository {
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
+ originYardId?: string | null;
+ destinationYardId?: string | null;
}): Promise {
const qb = this.repo
.createQueryBuilder('rate')
@@ -59,6 +61,18 @@ export class RatesRepository implements IRatesRepository {
} else {
qb.andWhere('rate.trade_direction IS NULL');
}
+ if (pattern.originYardId) {
+ qb.andWhere('rate.origin_yard_id = :originYardId', { originYardId: pattern.originYardId });
+ } else {
+ qb.andWhere('rate.origin_yard_id IS NULL');
+ }
+ if (pattern.destinationYardId) {
+ qb.andWhere('rate.destination_yard_id = :destinationYardId', {
+ destinationYardId: pattern.destinationYardId,
+ });
+ } else {
+ qb.andWhere('rate.destination_yard_id IS NULL');
+ }
return qb.getOne();
}
@@ -75,6 +89,10 @@ export class RatesRepository implements IRatesRepository {
findPaged(query: ListRatesQueryDto): Promise> {
const qb = this.repo
.createQueryBuilder('rate')
+ // The admin table shows the leg a base-freight rate prices — without the
+ // yards joined the route columns have only ids to render.
+ .leftJoinAndSelect('rate.originYard', 'originYard')
+ .leftJoinAndSelect('rate.destinationYard', 'destinationYard')
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
if (query.status) {
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..c429e3d45 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
@@ -6,7 +6,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
-import { PaginatedResponse } from '@edr/types';
+import { PaginatedResponse, YardCountry } from '@edr/types';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
@@ -14,12 +14,24 @@ import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
+import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
+
+/** Categories priced per rail leg — they carry an origin → destination yard pair. */
+const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
+
+/** The yard pair a rate scopes to, already validated against its direction. */
+interface YardScope {
+ originYardId: string | null;
+ destinationYardId: string | null;
+}
@Injectable()
export class RatesService {
constructor(
@Inject(RATES_REPOSITORY)
private readonly repository: IRatesRepository,
+ @Inject(YARDS_REPOSITORY)
+ private readonly yardsRepository: IYardsRepository,
) {}
/** List rates — standard paginated envelope with server-side search. */
@@ -62,6 +74,136 @@ export class RatesService {
return requestedUnit;
}
+ /** Base rail freight is priced per leg; surcharges and truck legs are not. */
+ private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
+ return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
+ }
+
+ /**
+ * Which country each end of the leg must sit in, given what the rate is for.
+ * The railway only sells three shapes: import lands at the Djibouti ports and
+ * rails inland, export is the reverse, and intercity stays inside Ethiopia.
+ */
+ private expectedYardCountries(
+ appliesTo: Rate['appliesTo'],
+ tradeDirection: string | null,
+ ): { origin: YardCountry; destination: YardCountry } {
+ if (appliesTo === 'INTERCITY') {
+ return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
+ }
+ return tradeDirection === 'EXPORT'
+ ? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI }
+ : { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA };
+ }
+
+ /**
+ * Validate and normalise the leg a rate prices.
+ *
+ * Base freight must name both yards and they must match the direction, so a
+ * "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything
+ * else (surcharges, first/last mile) is route-agnostic and has its yards
+ * cleared, mirroring how container/cargo scope is cleared for surcharges.
+ */
+ private async resolveYardScope(input: {
+ appliesTo: Rate['appliesTo'];
+ trigger: Rate['trigger'];
+ tradeDirection: string | null;
+ originYardId?: string | null;
+ destinationYardId?: string | null;
+ }): Promise {
+ const { appliesTo, trigger, tradeDirection } = input;
+ if (!this.isBaseFreight(appliesTo, trigger)) {
+ return { originYardId: null, destinationYardId: null };
+ }
+
+ const originYardId = input.originYardId ?? null;
+ const destinationYardId = input.destinationYardId ?? null;
+ if (!originYardId || !destinationYardId) {
+ throw new BadRequestException(
+ 'Base freight rates are priced per leg — pick both an origin and a destination yard.',
+ );
+ }
+ if (originYardId === destinationYardId) {
+ throw new BadRequestException('Origin and destination yard must be different.');
+ }
+
+ const [origin, destination] = await Promise.all([
+ this.yardsRepository.findById(originYardId),
+ this.yardsRepository.findById(destinationYardId),
+ ]);
+ if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`);
+ if (!destination) {
+ throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
+ }
+
+ const expected = this.expectedYardCountries(appliesTo, tradeDirection);
+ if (origin.country !== expected.origin || destination.country !== expected.destination) {
+ const shape =
+ appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`;
+ throw new BadRequestException(
+ `${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` +
+ `${origin.country} and ${destination.label} is in ${destination.country}.`,
+ );
+ }
+
+ return { originYardId, destinationYardId };
+ }
+
+ /**
+ * Guard the scope fields a base-freight category needs before we derive its
+ * rateType: import/export must say which, and intercity must say whether it
+ * carries containers or bulk (the two price differently and an unstated kind
+ * would silently file the rate as one of them).
+ */
+ private assertScopeCoherent(input: {
+ appliesTo: Rate['appliesTo'];
+ trigger: Rate['trigger'];
+ tradeDirection: string | null;
+ intercityKind: string | null;
+ containerTypeId: string | null;
+ cargoTypeId: string | null;
+ }): void {
+ const { appliesTo, trigger, tradeDirection, intercityKind } = input;
+ const { containerTypeId, cargoTypeId } = input;
+ if (!this.isBaseFreight(appliesTo, trigger)) return;
+
+ if (appliesTo === 'INTERCITY') {
+ if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') {
+ throw new BadRequestException(
+ 'An intercity rate must say whether it covers containers or bulk.',
+ );
+ }
+ // The scope field has to agree with the kind, or the rate would advertise
+ // one cargo kind and narrow by the other.
+ if (intercityKind === 'CONTAINER' && cargoTypeId) {
+ throw new BadRequestException(
+ 'An intercity container rate cannot be scoped to a bulk cargo type.',
+ );
+ }
+ if (intercityKind === 'BULK' && containerTypeId) {
+ throw new BadRequestException(
+ 'An intercity bulk rate cannot be scoped to a container type.',
+ );
+ }
+ return;
+ }
+
+ if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
+ throw new BadRequestException(
+ `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
+ );
+ }
+ }
+
+ /**
+ * Whether a rate covers bulk cargo — the flag `deriveRateType` splits
+ * INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind
+ * explicitly; for BULK/CONTAINER the category already says it.
+ */
+ private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean {
+ return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
+ }
+
/**
* Reject a second rate with the same identity pattern (rateType + scope). With
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
@@ -73,12 +215,14 @@ export class RatesService {
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
+ originYardId: string | null;
+ destinationYardId: string | null;
ignoreId?: string;
}): Promise {
const existing = await this.repository.findByPattern(pattern);
if (existing && existing.id !== pattern.ignoreId) {
throw new ConflictException(
- 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
+ 'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.',
);
}
}
@@ -92,17 +236,45 @@ export class RatesService {
const isSurcharge = trigger !== 'ALWAYS';
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
- const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
+ // Intercity never leaves Ethiopia, so it has no trade direction to store —
+ // its yard pair already says where it runs.
+ const tradeDirection =
+ isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
+
+ const intercityKind = dto.intercityKind ?? null;
+ this.assertScopeCoherent({
+ appliesTo,
+ trigger,
+ tradeDirection,
+ intercityKind,
+ containerTypeId,
+ cargoTypeId,
+ });
+ const { originYardId, destinationYardId } = await this.resolveYardScope({
+ appliesTo,
+ trigger,
+ tradeDirection,
+ originYardId: dto.originYardId,
+ destinationYardId: dto.destinationYardId,
+ });
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
- isBulk: Boolean(cargoTypeId),
+ isBulk: this.resolvesToBulk(appliesTo, intercityKind),
});
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
- await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
+ await this.assertNoDuplicatePattern({
+ rateType,
+ rateUnit,
+ containerTypeId,
+ cargoTypeId,
+ tradeDirection,
+ originYardId,
+ destinationYardId,
+ });
return this.repository.create({
appliesTo,
@@ -111,6 +283,8 @@ export class RatesService {
containerTypeId,
cargoTypeId,
tradeDirection,
+ originYardId,
+ destinationYardId,
currency: dto.currency ?? 'USD',
rateValue: dto.rateValue,
rateUnit,
@@ -119,12 +293,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;
@@ -144,21 +368,52 @@ export class RatesService {
: dto.cargoTypeId !== undefined
? dto.cargoTypeId
: existing.cargoTypeId;
- const tradeDirection = isSurcharge
- ? null
- : dto.tradeDirection !== undefined
- ? dto.tradeDirection
- : existing.tradeDirection;
+ const tradeDirection =
+ isSurcharge || appliesTo === 'INTERCITY'
+ ? null
+ : dto.tradeDirection !== undefined
+ ? dto.tradeDirection
+ : existing.tradeDirection;
updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null;
updates.tradeDirection = tradeDirection ?? null;
+
+ // A patch that leaves the cargo kind unsaid keeps the one the rate already
+ // has — read back off its rateType, the only place it is recorded.
+ const intercityKind =
+ dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER');
+
+ this.assertScopeCoherent({
+ appliesTo,
+ trigger,
+ tradeDirection: updates.tradeDirection,
+ intercityKind,
+ containerTypeId: updates.containerTypeId,
+ cargoTypeId: updates.cargoTypeId,
+ });
+ // Re-validate the leg: changing direction can invalidate a yard pair that
+ // was legal under the old one (an import route is not an export route).
+ const yardScope = await this.resolveYardScope({
+ appliesTo,
+ trigger,
+ tradeDirection: updates.tradeDirection,
+ originYardId:
+ dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
+ destinationYardId:
+ dto.destinationYardId !== undefined
+ ? dto.destinationYardId
+ : existing.destinationYardId,
+ });
+ updates.originYardId = yardScope.originYardId;
+ updates.destinationYardId = yardScope.destinationYardId;
+
// Keep the derived rateType in sync with whatever changed.
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
- isBulk: Boolean(cargoTypeId),
+ isBulk: this.resolvesToBulk(appliesTo, intercityKind),
});
updates.rateType = rateType;
@@ -174,14 +429,14 @@ export class RatesService {
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,
+ originYardId: updates.originYardId,
+ destinationYardId: updates.destinationYardId,
ignoreId: id,
});
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/support-chat/dto/list-conversations-query.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts
new file mode 100644
index 000000000..a4ebab160
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts
@@ -0,0 +1,34 @@
+import { ApiPropertyOptional } from "@nestjs/swagger";
+import { Transform, Type } from "class-transformer";
+import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
+
+export class ListConversationsQueryDto {
+ @ApiPropertyOptional({ description: "Search company name." })
+ @IsOptional()
+ @IsString()
+ search?: string;
+
+ @ApiPropertyOptional({
+ description: "Keep only threads with unread messages.",
+ default: false,
+ })
+ @IsOptional()
+ @Transform(({ value }) => value === true || value === "true")
+ @IsBoolean()
+ unreadOnly?: boolean;
+
+ @ApiPropertyOptional({ minimum: 1, default: 1 })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number;
+
+ @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ limit?: number;
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts
new file mode 100644
index 000000000..89178ef63
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts
@@ -0,0 +1,11 @@
+import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsString, MaxLength, MinLength } from "class-validator";
+
+export class SendMessageDto implements ISendSupportMessageDto {
+ @ApiProperty({ description: "Message text." })
+ @IsString()
+ @MinLength(1)
+ @MaxLength(4000)
+ body!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts
new file mode 100644
index 000000000..736ff13aa
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts
@@ -0,0 +1,10 @@
+import { StartSupportConversationDto as IStartSupportConversationDto } from "@edr/types";
+import { ApiProperty } from "@nestjs/swagger";
+import { IsUUID } from "class-validator";
+
+/** Agent opens the thread with a company before sending the first message. */
+export class StartConversationDto implements IStartSupportConversationDto {
+ @ApiProperty({ description: "Customer company to chat with." })
+ @IsUUID()
+ companyId!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts
new file mode 100644
index 000000000..4e74364d4
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts
@@ -0,0 +1,54 @@
+import { BaseEntity } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import { Column, Entity, Index } from "typeorm";
+
+/**
+ * The single support thread for a customer **company**. Any portal user of that
+ * company sees and continues it; backoffice agents work a shared inbox. Either
+ * side may open it — whoever sends the first message — and it has no lifecycle:
+ * no status, no resolve, no close.
+ *
+ * The unique index on `company_id` is what enforces one-thread-per-company; the
+ * get-or-create path relies on it to settle races. Last-message fields are
+ * denormalized so the inbox list can sort and preview without joining
+ * `support_messages`. Read cursors are per-side (shared across a company's
+ * users) — unread = messages from the other role newer than the side's cursor.
+ */
+@Entity({ schema: "freight", name: "support_conversations" })
+@Index("IDX_SUPPORT_CONV_COMPANY", ["companyId"], {
+ unique: true,
+ where: "deleted_at IS NULL",
+})
+@Index("IDX_SUPPORT_CONV_LASTMSG", ["lastMessageAt"])
+export class SupportConversation extends BaseEntity {
+ @Column({ name: "company_id", type: "uuid" })
+ companyId!: string;
+
+ /** Denormalized company name for the agent inbox (resolved at creation). */
+ @Column({ name: "company_name", type: "varchar", length: 200, nullable: true })
+ companyName?: string | null;
+
+ /** Null when an agent opened the thread — no customer created it. */
+ @Column({ name: "created_by_user_id", type: "uuid", nullable: true })
+ createdByUserId?: string | null;
+
+ @Column({ name: "last_message_at", type: "timestamptz", nullable: true })
+ lastMessageAt?: Date | null;
+
+ @Column({ name: "last_message_preview", type: "varchar", length: 280, nullable: true })
+ lastMessagePreview?: string | null;
+
+ @Column({
+ name: "last_message_author_role",
+ type: "varchar",
+ length: 12,
+ nullable: true,
+ })
+ lastMessageAuthorRole?: SupportAuthorRole | null;
+
+ @Column({ name: "customer_last_read_at", type: "timestamptz", nullable: true })
+ customerLastReadAt?: Date | null;
+
+ @Column({ name: "agent_last_read_at", type: "timestamptz", nullable: true })
+ agentLastReadAt?: Date | null;
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts
new file mode 100644
index 000000000..443a1694f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts
@@ -0,0 +1,24 @@
+import { BaseEntity } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import { Column, Entity, Index } from "typeorm";
+
+/** A single text message inside a {@link SupportConversation}. */
+@Entity({ schema: "freight", name: "support_messages" })
+@Index("IDX_SUPPORT_MSG_CONV_CREATED", ["conversationId", "createdAt"])
+export class SupportMessage extends BaseEntity {
+ @Column({ name: "conversation_id", type: "uuid" })
+ conversationId!: string;
+
+ @Column({ name: "author_user_id", type: "uuid" })
+ authorUserId!: string;
+
+ @Column({ name: "author_role", type: "varchar", length: 12 })
+ authorRole!: SupportAuthorRole;
+
+ /** Display name captured at send time (best-effort). */
+ @Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
+ authorName?: string | null;
+
+ @Column({ name: "body", type: "text" })
+ body!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts
new file mode 100644
index 000000000..efeb918be
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts
@@ -0,0 +1,76 @@
+import { CurrentUser } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from "@nestjs/common";
+import { ApiOperation, ApiTags } from "@nestjs/swagger";
+
+import {
+ AuthUserPayload,
+ resolveAuthUserId,
+} from "../../common/resolve-auth-user-id";
+import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
+import { SendMessageDto } from "./dto/send-message.dto";
+import { StartConversationDto } from "./dto/start-conversation.dto";
+import { SupportChatService } from "./support-chat.service";
+
+/** Backoffice (agent) support-chat endpoints. Shared inbox over all companies. */
+@ApiTags("support-chat-agent")
+@Controller("support/agent")
+export class SupportChatAgentController {
+ constructor(private readonly service: SupportChatService) {}
+
+ @Get("conversations")
+ @ApiOperation({ summary: "List all support threads (shared inbox)" })
+ list(@Query() query: ListConversationsQueryDto) {
+ return this.service.listForAgents(query);
+ }
+
+ @Post("conversations")
+ @ApiOperation({
+ summary: "Start chatting with a company (returns the thread if one exists)",
+ })
+ start(@Body() body: StartConversationDto) {
+ return this.service.startWithCompany(body.companyId);
+ }
+
+ @Get("conversations/:id/messages")
+ @ApiOperation({ summary: "List messages in a thread" })
+ messages(@Param("id", ParseUUIDPipe) id: string) {
+ return this.service.getMessages(id);
+ }
+
+ @Post("conversations/:id/messages")
+ @ApiOperation({ summary: "Reply as an agent" })
+ send(
+ @CurrentUser() user: AuthUserPayload,
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() body: SendMessageDto,
+ ) {
+ return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
+ }
+
+ @Post("conversations/:id/read")
+ @ApiOperation({ summary: "Mark a thread read (agent side)" })
+ read(
+ @CurrentUser() user: AuthUserPayload,
+ @Param("id", ParseUUIDPipe) id: string,
+ ) {
+ return this.service.markAgentRead(id, resolveAuthUserId(user));
+ }
+
+ @Get("unread-count")
+ @ApiOperation({ summary: "Count unread threads (agent side)" })
+ unread(@CurrentUser() user: AuthUserPayload) {
+ return this.service.unreadCount(
+ SupportAuthorRole.AGENT,
+ resolveAuthUserId(user),
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts
new file mode 100644
index 000000000..8d20ee91f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts
@@ -0,0 +1,59 @@
+import { CurrentUser } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import { Body, Controller, Get, Post } from "@nestjs/common";
+import { ApiOperation, ApiTags } from "@nestjs/swagger";
+
+import {
+ AuthUserPayload,
+ resolveAuthUserId,
+} from "../../common/resolve-auth-user-id";
+import { SendMessageDto } from "./dto/send-message.dto";
+import { SupportChatService } from "./support-chat.service";
+
+/**
+ * Portal (customer) support-chat endpoints. The caller's company has exactly one
+ * thread, so these are addressed as a singleton — no conversation id on the wire,
+ * and nothing for a portal user to pick between.
+ */
+@ApiTags("support-chat")
+@Controller("support")
+export class SupportChatController {
+ constructor(private readonly service: SupportChatService) {}
+
+ @Get("conversation")
+ @ApiOperation({
+ summary: "My company's support thread (null until someone speaks)",
+ })
+ conversation(@CurrentUser() user: AuthUserPayload) {
+ return this.service.getCustomerConversation(resolveAuthUserId(user));
+ }
+
+ @Get("conversation/messages")
+ @ApiOperation({ summary: "Messages in my company's support thread" })
+ messages(@CurrentUser() user: AuthUserPayload) {
+ return this.service.getCustomerMessages(resolveAuthUserId(user));
+ }
+
+ @Post("conversation/messages")
+ @ApiOperation({
+ summary: "Send a message as the customer, opening the thread if needed",
+ })
+ send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
+ return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
+ }
+
+ @Post("conversation/read")
+ @ApiOperation({ summary: "Mark my company's thread read (customer side)" })
+ read(@CurrentUser() user: AuthUserPayload) {
+ return this.service.markCustomerRead(resolveAuthUserId(user));
+ }
+
+ @Get("unread-count")
+ @ApiOperation({ summary: "Count my unread support messages" })
+ unread(@CurrentUser() user: AuthUserPayload) {
+ return this.service.unreadCount(
+ SupportAuthorRole.CUSTOMER,
+ resolveAuthUserId(user),
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts
new file mode 100644
index 000000000..e2e2875bc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts
@@ -0,0 +1,122 @@
+import {
+ SUPPORT_CHAT_WS_EVENTS,
+ SUPPORT_CHAT_WS_NAMESPACE,
+ SupportConversationDto,
+ SupportMessageDto,
+} from "@edr/types";
+import { Logger } from "@nestjs/common";
+import {
+ OnGatewayConnection,
+ WebSocketGateway,
+ WebSocketServer,
+} from "@nestjs/websockets";
+import { Server, Socket } from "socket.io";
+
+import { BackofficeService } from "../backoffice/backoffice.service";
+import { ExternalProfileRepository } from "../companies/external-profile.repository";
+import { WsAuthService } from "../notification-inbox/ws-auth.service";
+
+/**
+ * Server → client push for support chat. Clients only *listen* (no
+ * `@SubscribeMessage`); the handshake is authenticated in `handleConnection`
+ * (reusing the notification module's {@link WsAuthService}). Each socket joins a
+ * room based on its side:
+ * - backoffice staff → the shared `backoffice` room (see every conversation).
+ * - portal users → their `company:` room (their tickets only).
+ *
+ * A message is emitted to *both* the company room and the backoffice room so the
+ * customer thread, the sender's echo, and every other agent's inbox update live.
+ */
+@WebSocketGateway({
+ namespace: SUPPORT_CHAT_WS_NAMESPACE,
+ cors: { origin: true, credentials: true },
+})
+export class SupportChatGateway implements OnGatewayConnection {
+ private readonly logger = new Logger(SupportChatGateway.name);
+
+ private static readonly BACKOFFICE_ROOM = "backoffice";
+
+ @WebSocketServer()
+ private readonly server!: Server;
+
+ constructor(
+ private readonly wsAuth: WsAuthService,
+ private readonly backoffice: BackofficeService,
+ private readonly externalProfiles: ExternalProfileRepository,
+ ) {}
+
+ async handleConnection(socket: Socket): Promise {
+ const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
+ if (!userId) {
+ this.logger.debug(`Rejected support-chat handshake ${socket.id}`);
+ socket.disconnect(true);
+ return;
+ }
+ socket.data.userId = userId;
+
+ try {
+ const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
+ if (staffIds.includes(userId)) {
+ await socket.join(SupportChatGateway.BACKOFFICE_ROOM);
+ socket.data.side = "AGENT";
+ return;
+ }
+ } catch (err) {
+ this.logger.warn(`Staff lookup failed: ${(err as Error).message}`);
+ }
+
+ const profile = await this.externalProfiles.findByUserId(userId);
+ if (profile?.companyId) {
+ await socket.join(this.companyRoom(profile.companyId));
+ socket.data.side = "CUSTOMER";
+ socket.data.companyId = profile.companyId;
+ }
+ }
+
+ /** Push a new message + updated conversation to the company and backoffice rooms. */
+ emitMessage(
+ companyId: string,
+ conversation: SupportConversationDto,
+ message: SupportMessageDto,
+ ): void {
+ const payload = { conversation, message };
+ for (const room of this.targetRooms(companyId)) {
+ const to = this.server.to(room);
+ to.emit(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, payload);
+ to.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
+ }
+ }
+
+ /** Push a conversation metadata change (e.g. status) to both rooms. */
+ emitConversationUpdated(
+ companyId: string,
+ conversation: SupportConversationDto,
+ ): void {
+ for (const room of this.targetRooms(companyId)) {
+ this.server
+ .to(room)
+ .emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
+ }
+ }
+
+ private targetRooms(companyId: string): string[] {
+ return [this.companyRoom(companyId), SupportChatGateway.BACKOFFICE_ROOM];
+ }
+
+ private companyRoom(companyId: string): string {
+ return `company:${companyId}`;
+ }
+
+ private extractToken(socket: Socket): string | undefined {
+ const authToken = socket.handshake.auth?.token as string | undefined;
+ if (authToken) return authToken;
+
+ const queryToken = socket.handshake.query?.token;
+ if (typeof queryToken === "string") return queryToken;
+
+ const header = socket.handshake.headers?.authorization;
+ if (header?.startsWith("Bearer ")) return header.slice(7);
+
+ return undefined;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts
new file mode 100644
index 000000000..34accab44
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts
@@ -0,0 +1,35 @@
+import { Module } from "@nestjs/common";
+import { TypeOrmModule } from "@nestjs/typeorm";
+
+import { BackofficeModule } from "../backoffice/backoffice.module";
+import { CompaniesModule } from "../companies/companies.module";
+import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
+import { SupportConversation } from "./entities/support-conversation.entity";
+import { SupportMessage } from "./entities/support-message.entity";
+import { SupportChatAgentController } from "./support-chat-agent.controller";
+import { SupportChatController } from "./support-chat.controller";
+import { SupportChatGateway } from "./support-chat.gateway";
+import { SupportChatService } from "./support-chat.service";
+import { SupportConversationRepository } from "./support-conversation.repository";
+import { SupportMessageRepository } from "./support-message.repository";
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([SupportConversation, SupportMessage]),
+ // ExternalProfileRepository — company lookup + ownership checks.
+ // CompaniesService — resolve the company an agent opens a thread with.
+ CompaniesModule,
+ // BackofficeService.getAllCurrentEmployeeUserIds — staff room membership.
+ BackofficeModule,
+ // WsAuthService — reused handshake authentication for the gateway.
+ NotificationInboxModule,
+ ],
+ controllers: [SupportChatController, SupportChatAgentController],
+ providers: [
+ SupportConversationRepository,
+ SupportMessageRepository,
+ SupportChatGateway,
+ SupportChatService,
+ ],
+})
+export class SupportChatModule {}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts
new file mode 100644
index 000000000..70de1ce11
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts
@@ -0,0 +1,367 @@
+import {
+ SendSupportMessageResult,
+ SupportAuthorRole,
+ SupportConversationDto,
+ SupportConversationListResult,
+ SupportMessageDto,
+} from "@edr/types";
+import {
+ ForbiddenException,
+ Injectable,
+ NotFoundException,
+} from "@nestjs/common";
+import { QueryFailedError } from "typeorm";
+
+import { CompaniesService } from "../companies/companies.service";
+import { ExternalProfileRepository } from "../companies/external-profile.repository";
+import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
+import { SupportConversation } from "./entities/support-conversation.entity";
+import { SupportMessage } from "./entities/support-message.entity";
+import { SupportChatGateway } from "./support-chat.gateway";
+import { SupportConversationRepository } from "./support-conversation.repository";
+import { SupportMessageRepository } from "./support-message.repository";
+
+interface CustomerContext {
+ companyId: string;
+ companyName?: string | null;
+ authorName?: string | null;
+}
+
+/** Postgres unique_violation — the one-thread-per-company index fired. */
+const PG_UNIQUE_VIOLATION = "23505";
+
+@Injectable()
+export class SupportChatService {
+ constructor(
+ private readonly conversations: SupportConversationRepository,
+ private readonly messages: SupportMessageRepository,
+ private readonly gateway: SupportChatGateway,
+ private readonly externalProfiles: ExternalProfileRepository,
+ private readonly companies: CompaniesService,
+ ) {}
+
+ // ---- customer (portal) -------------------------------------------------
+
+ /**
+ * The caller's company thread, or null if nobody has spoken yet. Deliberately
+ * does *not* create: opening the widget shouldn't push an empty thread into
+ * the agent inbox. Creation happens on the first message.
+ */
+ async getCustomerConversation(
+ userId: string,
+ ): Promise {
+ const ctx = await this.resolveCustomer(userId);
+ const conversation = await this.conversations.findByCompanyId(ctx.companyId);
+ if (!conversation) return null;
+ const unread = await this.messages.unreadCountsByConversation(
+ [conversation.id],
+ SupportAuthorRole.CUSTOMER,
+ );
+ return this.toConversationDto(
+ conversation,
+ unread.get(conversation.id) ?? 0,
+ );
+ }
+
+ async getCustomerMessages(userId: string): Promise {
+ const ctx = await this.resolveCustomer(userId);
+ const conversation = await this.conversations.findByCompanyId(ctx.companyId);
+ if (!conversation) return [];
+ return this.listMessages(conversation.id);
+ }
+
+ /** Send as the customer, opening the thread if this is the first message. */
+ async sendAsCustomer(
+ userId: string,
+ body: string,
+ ): Promise {
+ const ctx = await this.resolveCustomer(userId);
+ const conversation = await this.getOrCreate(
+ ctx.companyId,
+ ctx.companyName,
+ userId,
+ );
+ const { conversation: updated, message } = await this.appendMessage(
+ conversation,
+ userId,
+ SupportAuthorRole.CUSTOMER,
+ body,
+ ctx.authorName,
+ );
+ return {
+ conversation: this.toConversationDto(updated, 0),
+ message: this.toMessageDto(message),
+ };
+ }
+
+ async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
+ const ctx = await this.resolveCustomer(userId);
+ const conversation = await this.conversations.findByCompanyId(ctx.companyId);
+ if (conversation) {
+ await this.conversations.update(conversation.id, {
+ customerLastReadAt: new Date(),
+ });
+ }
+ return this.unreadCount(SupportAuthorRole.CUSTOMER, userId);
+ }
+
+ // ---- agent (backoffice) ------------------------------------------------
+
+ async listForAgents(
+ query: ListConversationsQueryDto,
+ ): Promise {
+ const [rows, count] = await this.conversations.listAll(
+ SupportAuthorRole.AGENT,
+ query,
+ );
+ return this.buildListResult(rows, count, SupportAuthorRole.AGENT);
+ }
+
+ /**
+ * Open (or reuse) the thread with a company so an agent can start chatting.
+ * Idempotent — clicking a company that already has a thread just returns it.
+ */
+ async startWithCompany(companyId: string): Promise {
+ const company = await this.companies.findCompanyById(companyId);
+ const existing = await this.conversations.findByCompanyId(companyId);
+ const conversation =
+ existing ?? (await this.getOrCreate(companyId, company.name, null));
+
+ const unread = await this.messages.unreadCountsByConversation(
+ [conversation.id],
+ SupportAuthorRole.AGENT,
+ );
+ const dto = this.toConversationDto(
+ conversation,
+ unread.get(conversation.id) ?? 0,
+ );
+ if (!existing) {
+ // Surface the new thread in every agent's inbox right away.
+ this.gateway.emitConversationUpdated(conversation.companyId, dto);
+ }
+ return dto;
+ }
+
+ async sendAsAgent(
+ conversationId: string,
+ userId: string,
+ body: string,
+ ): Promise {
+ const conversation = await this.requireConversation(conversationId);
+ const { message } = await this.appendMessage(
+ conversation,
+ userId,
+ SupportAuthorRole.AGENT,
+ body,
+ );
+ return this.toMessageDto(message);
+ }
+
+ async markAgentRead(
+ conversationId: string,
+ userId: string,
+ ): Promise<{ unreadCount: number }> {
+ await this.requireConversation(conversationId);
+ await this.conversations.update(conversationId, {
+ agentLastReadAt: new Date(),
+ });
+ return this.unreadCount(SupportAuthorRole.AGENT, userId);
+ }
+
+ // ---- shared ------------------------------------------------------------
+
+ /**
+ * A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
+ * company owns it (portal route); omit for agents, who see every thread.
+ */
+ async getMessages(
+ conversationId: string,
+ asCustomerUserId?: string,
+ ): Promise {
+ const conversation = await this.requireConversation(conversationId);
+ if (asCustomerUserId) {
+ await this.assertCustomerOwns(conversation, asCustomerUserId);
+ }
+ return this.listMessages(conversationId);
+ }
+
+ async unreadCount(
+ side: SupportAuthorRole,
+ userId: string,
+ ): Promise<{ unreadCount: number }> {
+ if (side === SupportAuthorRole.CUSTOMER) {
+ const ctx = await this.resolveCustomer(userId);
+ return {
+ unreadCount: await this.messages.countUnreadConversations(
+ side,
+ ctx.companyId,
+ ),
+ };
+ }
+ return { unreadCount: await this.messages.countUnreadConversations(side) };
+ }
+
+ // ---- internals ---------------------------------------------------------
+
+ /**
+ * Fetch the company's thread or open it. Two first-messages can race here, so
+ * we let the unique index arbitrate and re-read the winner rather than
+ * locking — the loser's insert is the only wasted work.
+ */
+ private async getOrCreate(
+ companyId: string,
+ companyName: string | null | undefined,
+ createdByUserId: string | null,
+ ): Promise {
+ const existing = await this.conversations.findByCompanyId(companyId);
+ if (existing) return existing;
+
+ try {
+ return await this.conversations.create({
+ companyId,
+ companyName: companyName ?? null,
+ createdByUserId,
+ });
+ } catch (error) {
+ if (
+ error instanceof QueryFailedError &&
+ (error as QueryFailedError & { code?: string }).code ===
+ PG_UNIQUE_VIOLATION
+ ) {
+ const winner = await this.conversations.findByCompanyId(companyId);
+ if (winner) return winner;
+ }
+ throw error;
+ }
+ }
+
+ private async listMessages(
+ conversationId: string,
+ ): Promise {
+ const rows = await this.messages.listByConversation(conversationId);
+ return rows.map((m) => this.toMessageDto(m));
+ }
+
+ /** Persist a message, bump the conversation's denormalized fields, emit live. */
+ private async appendMessage(
+ conversation: SupportConversation,
+ userId: string,
+ role: SupportAuthorRole,
+ body: string,
+ authorName?: string | null,
+ ): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
+ const message = await this.messages.create({
+ conversationId: conversation.id,
+ authorUserId: userId,
+ authorRole: role,
+ authorName: authorName ?? null,
+ body,
+ });
+
+ conversation.lastMessageAt = message.createdAt;
+ conversation.lastMessagePreview = body.slice(0, 280);
+ conversation.lastMessageAuthorRole = role;
+ await this.conversations.update(conversation.id, {
+ lastMessageAt: conversation.lastMessageAt,
+ lastMessagePreview: conversation.lastMessagePreview,
+ lastMessageAuthorRole: role,
+ });
+
+ const dto = this.toConversationDto(conversation, 0);
+ this.gateway.emitMessage(
+ conversation.companyId,
+ dto,
+ this.toMessageDto(message),
+ );
+ return { conversation, message };
+ }
+
+ private async buildListResult(
+ rows: SupportConversation[],
+ count: number,
+ side: SupportAuthorRole,
+ companyId?: string,
+ ): Promise {
+ const unreadMap = await this.messages.unreadCountsByConversation(
+ rows.map((r) => r.id),
+ side,
+ );
+ const items = rows.map((r) =>
+ this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
+ );
+ const unreadCount = await this.messages.countUnreadConversations(
+ side,
+ companyId,
+ );
+ return { items, count, unreadCount };
+ }
+
+ private async resolveCustomer(userId: string): Promise {
+ const profile = await this.externalProfiles.findByUserId(userId);
+ if (!profile?.companyId) {
+ throw new ForbiddenException(
+ "No company profile is linked to this account.",
+ );
+ }
+ const name = [profile.firstName, profile.lastName]
+ .filter(Boolean)
+ .join(" ")
+ .trim();
+ return {
+ companyId: profile.companyId,
+ companyName: profile.company?.name ?? null,
+ authorName: name || null,
+ };
+ }
+
+ private async assertCustomerOwns(
+ conversation: SupportConversation,
+ userId: string,
+ ): Promise {
+ const ctx = await this.resolveCustomer(userId);
+ if (conversation.companyId !== ctx.companyId) {
+ throw new ForbiddenException("This conversation belongs to another company.");
+ }
+ return ctx;
+ }
+
+ private async requireConversation(id: string): Promise {
+ const conversation = await this.conversations.findById(id);
+ if (!conversation) {
+ throw new NotFoundException("Conversation not found.");
+ }
+ return conversation;
+ }
+
+ private toConversationDto(
+ c: SupportConversation,
+ unreadCount: number,
+ ): SupportConversationDto {
+ return {
+ id: c.id,
+ companyId: c.companyId,
+ companyName: c.companyName ?? null,
+ createdByUserId: c.createdByUserId ?? null,
+ lastMessageAt: c.lastMessageAt
+ ? new Date(c.lastMessageAt).toISOString()
+ : null,
+ lastMessagePreview: c.lastMessagePreview ?? null,
+ lastMessageAuthorRole: c.lastMessageAuthorRole ?? null,
+ unreadCount,
+ createdAt: new Date(c.createdAt).toISOString(),
+ updatedAt: new Date(c.updatedAt).toISOString(),
+ };
+ }
+
+ private toMessageDto(m: SupportMessage): SupportMessageDto {
+ return {
+ id: m.id,
+ conversationId: m.conversationId,
+ authorUserId: m.authorUserId,
+ authorRole: m.authorRole,
+ authorName: m.authorName ?? null,
+ body: m.body,
+ createdAt: new Date(m.createdAt).toISOString(),
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts
new file mode 100644
index 000000000..10bdfcb4a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts
@@ -0,0 +1,75 @@
+import { BaseRepository } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { SupportConversation } from "./entities/support-conversation.entity";
+
+export interface ListConversationsOptions {
+ search?: string;
+ /** Keep only threads with at least one message the side hasn't read. */
+ unreadOnly?: boolean;
+ page?: number;
+ limit?: number;
+}
+
+@Injectable()
+export class SupportConversationRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(SupportConversation)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ /** The company's thread, or null if neither side has spoken yet. */
+ async findByCompanyId(companyId: string): Promise {
+ return this.repository.findOne({ where: { companyId } });
+ }
+
+ /** Every thread (backoffice shared inbox), most-recently-active first. */
+ async listAll(
+ side: SupportAuthorRole,
+ opts: ListConversationsOptions = {},
+ ): Promise<[SupportConversation[], number]> {
+ const page = opts.page && opts.page > 0 ? opts.page : 1;
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
+ const qb = this.repository
+ .createQueryBuilder("c")
+ .orderBy("c.last_message_at", "DESC", "NULLS LAST")
+ .addOrderBy("c.created_at", "DESC")
+ .skip((page - 1) * limit)
+ .take(limit);
+
+ if (opts.search?.trim()) {
+ qb.andWhere("c.company_name ILIKE :term", {
+ term: `%${opts.search.trim()}%`,
+ });
+ }
+ if (opts.unreadOnly) {
+ // Same rule as SupportMessageRepository.baseUnreadQuery: a message from
+ // the other role, newer than this side's cursor. `cursorCol` is chosen
+ // from a closed set below — never caller input.
+ const otherRole =
+ side === SupportAuthorRole.CUSTOMER
+ ? SupportAuthorRole.AGENT
+ : SupportAuthorRole.CUSTOMER;
+ const cursorCol =
+ side === SupportAuthorRole.CUSTOMER
+ ? "c.customer_last_read_at"
+ : "c.agent_last_read_at";
+ qb.andWhere(
+ `EXISTS (
+ SELECT 1 FROM freight.support_messages m
+ WHERE m.conversation_id = c.id
+ AND m.deleted_at IS NULL
+ AND m.author_role = :otherRole
+ AND (${cursorCol} IS NULL OR m.created_at > ${cursorCol})
+ )`,
+ { otherRole },
+ );
+ }
+ return qb.getManyAndCount();
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts
new file mode 100644
index 000000000..827035320
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts
@@ -0,0 +1,82 @@
+import { BaseRepository } from "@edr/api-common";
+import { SupportAuthorRole } from "@edr/types";
+import { Injectable } from "@nestjs/common";
+import { InjectRepository } from "@nestjs/typeorm";
+import { Repository } from "typeorm";
+
+import { SupportConversation } from "./entities/support-conversation.entity";
+import { SupportMessage } from "./entities/support-message.entity";
+
+@Injectable()
+export class SupportMessageRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(SupportMessage)
+ repo: Repository,
+ ) {
+ super(repo);
+ }
+
+ /** All messages of a conversation, oldest first. */
+ async listByConversation(conversationId: string): Promise {
+ return this.repository.find({
+ where: { conversationId },
+ order: { createdAt: "ASC" },
+ });
+ }
+
+ /**
+ * Unread message counts per conversation *for one side*: messages authored by
+ * the other role that are newer than the side's read cursor. Returns a map of
+ * conversationId → count (conversations with 0 unread are absent).
+ */
+ async unreadCountsByConversation(
+ conversationIds: string[],
+ mySide: SupportAuthorRole,
+ ): Promise> {
+ if (conversationIds.length === 0) return new Map();
+ const rows = await this.baseUnreadQuery(mySide)
+ .select("m.conversation_id", "conversationId")
+ .addSelect("COUNT(*)", "count")
+ .andWhere("m.conversation_id IN (:...ids)", { ids: conversationIds })
+ .groupBy("m.conversation_id")
+ .getRawMany<{ conversationId: string; count: string }>();
+ return new Map(rows.map((r) => [r.conversationId, Number(r.count)]));
+ }
+
+ /** Number of distinct conversations with at least one unread message for the side. */
+ async countUnreadConversations(
+ mySide: SupportAuthorRole,
+ companyId?: string,
+ ): Promise {
+ const qb = this.baseUnreadQuery(mySide).select(
+ "COUNT(DISTINCT m.conversation_id)",
+ "count",
+ );
+ if (companyId) {
+ qb.andWhere("c.company_id = :companyId", { companyId });
+ }
+ const row = await qb.getRawOne<{ count: string }>();
+ return Number(row?.count ?? 0);
+ }
+
+ /**
+ * Base query for "unread for `mySide`": join the conversation, keep only
+ * messages from the opposite role that are newer than the side's read cursor.
+ */
+ private baseUnreadQuery(mySide: SupportAuthorRole) {
+ const otherRole =
+ mySide === SupportAuthorRole.CUSTOMER
+ ? SupportAuthorRole.AGENT
+ : SupportAuthorRole.CUSTOMER;
+ const cursorCol =
+ mySide === SupportAuthorRole.CUSTOMER
+ ? "c.customer_last_read_at"
+ : "c.agent_last_read_at";
+ return this.repository
+ .createQueryBuilder("m")
+ .innerJoin(SupportConversation, "c", "c.id = m.conversation_id")
+ .where("m.deleted_at IS NULL")
+ .andWhere("m.author_role = :otherRole", { otherRole })
+ .andWhere(`(${cursorCol} IS NULL OR m.created_at > ${cursorCol})`);
+ }
+}
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/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
index d2f1dd7b1..f54dc138e 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
@@ -40,6 +40,68 @@ export class IntercityService {
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
+ /**
+ * Every intercity booking and where it is in its ride-along, across all trains.
+ *
+ * The per-schedule candidate list answers "what can THIS train carry"; this
+ * answers "what is happening to intercity cargo" — which is what a yard
+ * operator needs when the work is spread over whichever trains happen to pass.
+ *
+ * Carries each end's facility state, because a booking whose origin or
+ * destination has no facility can never be loaded or unloaded there and the
+ * operator should see that before the train arrives, not when the load is
+ * refused.
+ */
+ async listBookings() {
+ return this.dataSource.query(
+ `SELECT b.id AS "bookingId",
+ b.reference AS "reference",
+ b.status AS "status",
+ b.freight_type AS "freightType",
+ b.cargo_total_weight_vgm AS "weightTons",
+ b.loaded_at AS "loadedAt",
+ b.arrived_at AS "arrivedAt",
+ company.name AS "customer",
+ b.train_schedule_id AS "trainScheduleId",
+ ts.train_number AS "trainNumber",
+ ts.status AS "scheduleStatus",
+ oy.id AS "originYardId",
+ COALESCE(oy.label, oy.code) AS "origin",
+ oy.has_facility AS "originHasFacility",
+ dy.id AS "destinationYardId",
+ COALESCE(dy.label, dy.code) AS "destination",
+ dy.has_facility AS "destinationHasFacility",
+ -- Where the train actually is, so the operator knows if the cargo
+ -- can be worked right now.
+ cp.yard_id AS "trainAtYardId",
+ -- Most recent GRN raised for this booking at a facility.
+ fh.grn_number AS "grnNumber"
+ FROM freight.bookings b
+ LEFT JOIN freight.companies company ON company.id = b.company_id
+ LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
+ LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
+ LEFT JOIN freight.train_schedules ts
+ ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
+ LEFT JOIN LATERAL (
+ SELECT c.yard_id
+ FROM freight.train_checkpoint_events c
+ WHERE c.train_schedule_id = b.train_schedule_id
+ ORDER BY c.occurred_at DESC, c.created_at DESC
+ LIMIT 1
+ ) cp ON true
+ LEFT JOIN LATERAL (
+ SELECT e.grn_number
+ FROM freight.facility_handling_events e
+ WHERE e.booking_id = b.id AND e.deleted_at IS NULL
+ ORDER BY e.occurred_at DESC
+ LIMIT 1
+ ) fh ON true
+ WHERE b.deleted_at IS NULL
+ AND b.trade_direction = 'DOMESTIC'
+ ORDER BY b.created_at DESC`,
+ );
+ }
+
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index bbb19dbee..6bc36c4fc 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -459,6 +459,16 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
+ @Get("intercity/bookings")
+ @TrainSchedulingView()
+ @ApiOperation({
+ summary:
+ "Every intercity booking with its ride-along state, both yards' facility status, and where its train is",
+ })
+ listIntercityBookings() {
+ return this.intercityService.listBookings();
+ }
+
@Get("schedules/:id/intercity-candidates")
@TrainSchedulingView()
@ApiOperation({
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 b92a7ea90..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,
@@ -19,6 +21,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
+import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
DataSource,
EntityManager,
@@ -95,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,
@@ -317,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,
) {}
/**
@@ -591,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) {
@@ -599,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;
@@ -645,7 +663,7 @@ export class TrainSchedulingService {
await this.restampPendingWindows();
}
- return saved;
+ return this.toPublicGlobalRules(saved);
}
/**
@@ -2029,6 +2047,58 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
+ /**
+ * EXPORT ONLY. An export train must not leave carrying nothing while its cargo
+ * sits in the shed: the goods are received into the origin warehouse, GRN'd and
+ * loaded onto the wagons allocated to the booking, so anything still in the
+ * warehouse at dispatch is being left behind. Blocks dispatch when an allocated
+ * booking has warehouse inventory that never made it onto a wagon (received /
+ * stored / ready but not LOADED) — either load it from the Load-to-Train queue,
+ * or drop the booking's wagon allocation so it rides a later train.
+ *
+ * Import/domestic are untouched: their cargo isn't loaded out of an origin
+ * warehouse, so warehouse inventory says nothing about what's aboard.
+ *
+ * Bookings with no warehouse inventory at all are NOT blocked — allocating a
+ * wagon before the goods arrive is normal planning; they simply aren't aboard.
+ */
+ private async assertAllocatedCargoLoaded(scheduleId: string): Promise {
+ const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
+ await this.dataSource.query(
+ `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
+ FROM freight.train_schedules ts
+ LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
+ LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
+ WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
+ [scheduleId],
+ );
+ if (!route) return;
+ const direction = deriveTradeDirection(
+ { country: route.originCountry },
+ { country: route.destinationCountry },
+ );
+ if (direction !== 'EXPORT') return;
+
+ const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
+ `WITH ${SCHEDULE_BOOKINGS_CTE}
+ SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
+ FROM sched_bookings sb
+ JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
+ JOIN freight.warehouse_inventory inv
+ ON inv.booking_id = b.id AND inv.deleted_at IS NULL
+ WHERE sb.schedule_id = $1
+ AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
+ [scheduleId],
+ );
+ if (rows.length) {
+ const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
+ throw new BadRequestException(
+ `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
+ `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
+ );
+ }
+ }
+
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -2038,6 +2108,8 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
+ // Export only: don't leave received cargo behind in the warehouse.
+ await this.assertAllocatedCargoLoaded(scheduleId);
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
@@ -5054,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,
@@ -5092,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),
@@ -5284,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/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts
index 23b517785..c7eecfc02 100644
--- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts
+++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts
@@ -29,6 +29,13 @@ export class ListWagonsQueryDto {
@IsUUID()
trainId?: string;
+ @ApiPropertyOptional({
+ description: 'Filter by run number — matches export OR import run (e.g. 8001).',
+ })
+ @IsOptional()
+ @IsString()
+ trainNumber?: string;
+
@ApiPropertyOptional({ default: 'wagonNumber' })
@IsOptional()
@IsString()
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..188bf1783 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
@@ -6,7 +6,7 @@ import {
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
+import { Repository, DataSource, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -38,26 +38,47 @@ 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);
}
async findAll(query: ListWagonsQueryDto = {}): Promise {
- const where: FindOptionsWhere[] | FindOptionsWhere = [];
const search = query.search?.trim();
const trainId = query.trainId?.trim();
const wagonTypeId = query.wagonTypeId?.trim();
- const filters: FindOptionsWhere = {
- ...(query.status ? { status: query.status } : {}),
- ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
- ...(trainId ? { trainId } : {}),
- ...(wagonTypeId ? { wagonTypeId } : {}),
- };
+ const trainNumber = query.trainNumber?.trim();
+ // QueryBuilder (not find) because both search and the trainNumber filter span
+ // two columns each (export/import run) — an OR that FindOptions cannot express
+ // without cross-producting into conflicting branches. Soft-deleted rows are
+ // still excluded automatically (BaseEntity's @DeleteDateColumn).
+ const qb = this.wagonRepo
+ .createQueryBuilder('w')
+ .leftJoinAndSelect('w.currentYard', 'currentYard')
+ .leftJoinAndSelect('w.wagonType', 'wagonType');
+
+ if (query.status) qb.andWhere('w.status = :status', { status: query.status });
+ if (query.currentYardId)
+ qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
+ if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
+ if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
+
+ // Filter by run: the odd export run identifies the pair, so match either
+ // column — a wagon carries export on one, import on the other.
+ if (trainNumber) {
+ qb.andWhere(
+ '(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)',
+ { trainNumber },
+ );
+ }
+
+ // Search matches the wagon number or either run number.
if (search) {
- where.push({
- wagonNumber: ILike(`%${search}%`),
- ...filters,
- });
+ qb.andWhere(
+ '(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)',
+ { search: `%${search}%` },
+ );
}
// Spec columns (tare, payload) are no longer sortable here — they live on the
@@ -73,14 +94,14 @@ export class WagonsService {
? (query.sortBy as keyof Wagon)
: 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
+ qb.orderBy(`w.${sortBy}`, sortOrder);
- return this.wagonRepo.find({
- where: search ? where : filters,
- relations: { currentYard: true, wagonType: true },
- order: { [sortBy]: sortOrder } as FindOptionsOrder,
- skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
- take: query.limit ? Number(query.limit) : undefined,
- });
+ if (query.page && query.limit) {
+ qb.skip((Number(query.page) - 1) * Number(query.limit));
+ }
+ if (query.limit) qb.take(Number(query.limit));
+
+ return qb.getMany();
}
async findById(id: string): Promise {
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 07c720cff..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,8 @@ 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';
import { Company } from '../companies/entities/company.entity';
@@ -1031,6 +1033,8 @@ export class WarehouseInventoryService {
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
continue;
}
+ // EXPORT goods get their GRN on arrival at the warehouse — nothing loads
+ // onto a train without one. Import GRN handling is left untouched.
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
@@ -1040,6 +1044,9 @@ export class WarehouseInventoryService {
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
+ ...(booking.tradeDirection === 'EXPORT'
+ ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
+ : {}),
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
});
result.processedCount += 1;
@@ -1060,6 +1067,14 @@ export class WarehouseInventoryService {
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
+ // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
+ // a train without one. Import GRN handling is left untouched.
+ const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
+ `SELECT trade_direction AS "tradeDirection"
+ FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
+ [bookingId],
+ );
+ const isExport = bookingRow?.tradeDirection === 'EXPORT';
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
@@ -1080,6 +1095,10 @@ export class WarehouseInventoryService {
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
+ // Export only, and keep an already-issued GRN rather than reissuing.
+ ...(isExport && !existing[0].grnNumber
+ ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
+ : {}),
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
@@ -1094,6 +1113,9 @@ export class WarehouseInventoryService {
weight: 0,
status: 'RECEIVED',
arrivedAt,
+ ...(isExport
+ ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
+ : {}),
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
@@ -1542,11 +1564,19 @@ export class WarehouseInventoryService {
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
+ /**
+ * Export flow this queue serves: booked -> paid -> received at the warehouse
+ * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
+ * booking. Which bookings ride a train comes from the shared CTE.
+ */
+ private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
+
async loadableTrains(): Promise {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
- `SELECT ts.id AS "scheduleId",
+ `WITH ${this.SCHEDULE_BOOKINGS_CTE}
+ SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
@@ -1554,15 +1584,15 @@ export class WarehouseInventoryService {
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
- (SELECT count(*) FROM freight.train_schedule_bookings tsb
+ (SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
- ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
- WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
- AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
- (SELECT count(*) FROM freight.train_schedule_bookings tsb
+ ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
+ WHERE sb.schedule_id = ts.id
+ AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
+ (SELECT count(*) FROM sched_bookings sb
JOIN freight.warehouse_inventory inv
- ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
- WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
+ ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
+ WHERE sb.schedule_id = ts.id
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
@@ -1570,11 +1600,11 @@ export class WarehouseInventoryService {
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
- SELECT 1 FROM freight.train_schedule_bookings tsb2
+ SELECT 1 FROM sched_bookings sb2
JOIN freight.warehouse_inventory inv2
- ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
- WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
- AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
+ ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
+ WHERE sb2.schedule_id = ts.id
+ AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
@@ -1599,22 +1629,28 @@ export class WarehouseInventoryService {
*/
async trainLoadableItems(scheduleId: string): Promise {
const rows: Array> = await this.dataSource.query(
- `SELECT inv.id AS "id",
+ `WITH ${this.SCHEDULE_BOOKINGS_CTE}
+ SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
- substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
+ -- receive() stamps the GRN onto the row and mirrors it into the
+ -- note; prefer the column and fall back for legacy/seeded rows.
+ COALESCE(
+ inv.grn_number,
+ substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
+ ) AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
- FROM freight.train_schedule_bookings tsb
- JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
- JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
+ FROM sched_bookings sb
+ JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
+ JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
@@ -1631,15 +1667,19 @@ export class WarehouseInventoryService {
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
- WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
- AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
+ WHERE sb.schedule_id = $1
+ AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
- loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
+ // Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
+ // The row only exists once the goods were received, so requiring a GRN and
+ // an allocated wagon completes the chain.
+ loadable:
+ r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
}));
}
@@ -1692,6 +1732,9 @@ export class WarehouseInventoryService {
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
+ // Export: the GRN is raised when the goods arrive at the warehouse, and
+ // nothing rides a train without one.
+ if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
@@ -5031,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-trucks.ts b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts
new file mode 100644
index 000000000..07d277937
--- /dev/null
+++ b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts
@@ -0,0 +1,39 @@
+import { AppDataSource } from '../data-source';
+import { EdrTruckFleetSeeder } from '../seed/edr-truck-fleet.seeder';
+
+/**
+ * Seeds the 62-truck EDR fleet used by first-mile / last-mile.
+ *
+ * The seeder is idempotent (`ON CONFLICT (plate_number) DO NOTHING`), so a
+ * re-run will NOT overwrite a truck whose rate was tuned by hand.
+ */
+async function seedEdrTrucks() {
+ await AppDataSource.initialize();
+
+ try {
+ await new EdrTruckFleetSeeder(AppDataSource).run();
+
+ const summary = await AppDataSource.query(`
+ SELECT
+ COUNT(*)::int AS trucks,
+ COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active,
+ COUNT(*) FILTER (WHERE availability = 'FREE')::int AS free,
+ COUNT(*) FILTER (WHERE price_per_km > 0)::int AS priced,
+ COUNT(*) FILTER (WHERE price_per_km IS NULL OR price_per_km <= 0)::int AS unpriced,
+ MIN(price_per_km)::text AS min_rate,
+ MAX(price_per_km)::text AS max_rate
+ FROM freight.vehicles
+ WHERE vehicle_type = 'TRUCK';
+ `);
+
+ console.table(summary);
+ console.log('Seeded EDR truck fleet.');
+ } finally {
+ await AppDataSource.destroy();
+ }
+}
+
+seedEdrTrucks().catch((error) => {
+ console.error('Failed to seed EDR truck fleet:', error);
+ process.exit(1);
+});
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..c333abf40 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,9 @@
import { AppDataSource } from '../data-source';
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
+import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers';
+import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers';
+import { WagonNumberPartialUnique2280000000000 } from '../migrations/2280000000000-WagonNumberPartialUnique';
+import { SeedWagonYardDoraleh2290000000000 } from '../migrations/2290000000000-SeedWagonYardDoraleh';
async function seedEdRWagons() {
await AppDataSource.initialize();
@@ -10,7 +14,18 @@ async function seedEdRWagons() {
await queryRunner.connect();
await queryRunner.startTransaction();
+ // Fleet first (recreates every wagon with NULL yard + NULL runs), then the
+ // columns are ensured to exist, then the run roster and the yard are 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);
+ // Not a wagon seed, but it owns wagon_number uniqueness — included so this
+ // script leaves the same schema a real `migration:run` would, rather than a
+ // database missing the partial unique index.
+ await new WagonNumberPartialUnique2280000000000().up(queryRunner);
+ await new SeedWagonRunNumbers2280000000000().up(queryRunner);
+ await new SeedWagonYardDoraleh2290000000000().up(queryRunner);
const summary = await queryRunner.query(`
SELECT
@@ -20,7 +35,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 no_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 +45,45 @@ 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 yards = await queryRunner.query(`
+ SELECT
+ COALESCE(y.label, '(no yard)') AS yard,
+ COUNT(*)::int AS wagons
+ FROM freight.wagons w
+ LEFT JOIN freight.yards y ON y.id = w.current_yard_id
+ GROUP BY y.label
+ ORDER BY 2 DESC;
+ `);
+
+ 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('Fleet by yard:');
+ console.table(yards);
+ 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/edr-truck-fleet.seeder.ts b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts
index e8349c806..95a0221c2 100644
--- a/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts
+++ b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts
@@ -34,6 +34,16 @@ const EDR_TRUCK_FLEET: ReadonlyArray = [
/** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */
const TWENTY_FT_SEQS = new Set();
+/**
+ * Haulage rate for the EDR truck fleet, ETB per km.
+ *
+ * First/last-mile billing is `distance × pricePerKm` (see FirstMileService /
+ * LastMileService `setDistances`), and a truck with no rate is refused at
+ * assignment. Applies to the whole fleet — override per truck in the fleet UI
+ * where a specific truck differs.
+ */
+const TRUCK_PRICE_PER_KM_ETB = 20000;
+
@Injectable()
export class EdrTruckFleetSeeder {
private readonly logger = new Logger(EdrTruckFleetSeeder.name);
@@ -50,7 +60,7 @@ export class EdrTruckFleetSeeder {
const columns = [
'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no',
'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity',
- 'status', 'ownership', 'currency', 'description',
+ 'status', 'ownership', 'currency', 'price_per_km', 'description',
];
const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => {
@@ -71,6 +81,7 @@ export class EdrTruckFleetSeeder {
'ACTIVE',
'EDR',
'ETB',
+ TRUCK_PRICE_PER_KM_ETB,
`EDR-owned container truck configured for ${ft} containers.`,
];
});
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..16cbd8507 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -24,6 +24,8 @@ import {
Truck,
Users,
Wallet,
+ LifeBuoy,
+ TrainFront,
} from "lucide-react";
import { useEffect } from "react";
import {
@@ -121,6 +123,7 @@ import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPag
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
+import IntercityPage from "./pages/warehouses/IntercityPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
@@ -131,6 +134,7 @@ import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
+import SupportInboxPage from "./pages/support/SupportInboxPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -142,14 +146,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 +156,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 +169,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
- {
- label: "Customers",
- href: "/dashboard/customers",
- icon: ,
- },
{
label: "Payments",
href: "/dashboard/payments",
@@ -182,187 +181,194 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
permission: FREIGHT_PERMS.bookings.view,
},
+ {
+ label: "Support",
+ href: "/dashboard/support",
+ icon: ,
+ },
...demoItems,
],
},
{
- 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 +443,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
],
},
- ],
- },
- {
- title: "Warehouse Management",
- items: [
{
- label: "Warehouse Dashboard",
- href: "/dashboard/warehouse-dashboard",
- icon: ,
+ label: "Intercity",
+ href: "/dashboard/intercity",
+ icon: ,
+ children: [
+ {
+ label: "Intercity Cargo",
+ href: "/dashboard/intercity",
+ 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 +505,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
permission: FREIGHT_PERMS.admin,
},
- ],
- },
- {
- title: "Freight configuration",
- mutedTitle: true,
- items: [
{
label: "Configuration",
href: "/dashboard/configuration",
@@ -513,6 +527,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
children: getCategorySidebarChildren("rules"),
},
+
+ {
+ label: "Staff",
+ href: "/user-management",
+ icon: ,
+ },
],
},
];
@@ -599,7 +619,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 +697,7 @@ const App = () => {
} />
{/* } /> */}
- }
- />
+ } />
} />
} />
@@ -713,6 +733,7 @@ const App = () => {
}
/>
+ } />
} />
} />
{
} />
} />
} />
+ } />
} />
} />
{isViewable({
name: doc.file.name,
- url: fileViewUrl(doc.file.id),
+ url: "",
}) && (
- view({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(
+ doc.file!.id,
+ doc.file!.name,
+ ).then(view)
}
c="edr-green"
style={{
@@ -298,10 +301,21 @@ export function ClearanceReviewSection({
)}
+ void downloadBookingFile(
+ doc.file!.id,
+ doc.file!.name,
+ )
+ }
c="edr-green"
- style={{ display: "flex" }}
+ style={{
+ display: "flex",
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ }}
>
@@ -544,7 +558,7 @@ function DocReviewCard({
{hasFile &&
isViewable({
name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
+ url: "",
}) && (
}
onClick={() =>
- onView({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(doc.file!.id, doc.file!.name).then(
+ onView,
+ )
}
>
View
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx
index 17e5e4b70..8428edf52 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx
@@ -14,7 +14,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
@@ -97,8 +97,7 @@ function WorkflowFileRow({
const file = item.file;
if (!file) return null;
- const viewUrl = fileViewUrl(file.id);
- const canPreview = isViewable({ name: file.name, url: viewUrl });
+ const canPreview = isViewable({ name: file.name, url: "" });
return (
@@ -129,7 +128,9 @@ function WorkflowFileRow({
variant="default"
radius="md"
leftSection={ }
- onClick={() => onView({ name: file.name, url: viewUrl })}
+ onClick={() =>
+ void fetchViewableFile(file.id, file.name).then(onView)
+ }
>
View
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx
index f2383b649..c85dc0b07 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx
@@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
-import { fileViewUrl } from "@/constants/apiConfig";
+import {
+ downloadBookingFile,
+ fetchViewableFile,
+} from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({
<>
{isViewable({
name: doc.file.name,
- url: fileViewUrl(doc.file.id),
+ url: "",
}) && (
- view({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(
+ doc.file!.id,
+ doc.file!.name,
+ ).then(view)
}
c="edr-green"
style={{
@@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({
)}
+ void downloadBookingFile(
+ doc.file!.id,
+ doc.file!.name,
+ )
+ }
c="edr-green"
- style={{ display: "flex" }}
+ style={{
+ display: "flex",
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ }}
>
@@ -611,7 +625,7 @@ function DocReviewCard({
{hasFile &&
isViewable({
name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
+ url: "",
}) && (
}
onClick={() =>
- onView({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(doc.file!.id, doc.file!.name).then(
+ onView,
+ )
}
>
View
@@ -633,8 +646,11 @@ function DocReviewCard({
{hasFile && (
+ void downloadBookingFile(doc.file!.id, doc.file!.name)
+ }
size="compact-xs"
variant="default"
radius="md"
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index b7807f3cd..5633745aa 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity || 0),
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
+ returnQuantity: Number(l.returnQuantity || 0),
})),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
allowedSizes: containerSizes,
includeHazardous: contract?.isHazardous ?? false,
includeReefer: contract?.isReefer ?? false,
+ includeReturn: contractWithReturn,
};
const handleImportFile = async (file: File | null) => {
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
- returnQuantity:
- prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
+ returnQuantity: String(imported.filter((r) => r.withReturn).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedUploadedFileRow.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedUploadedFileRow.tsx
index 4666b7588..898c0c869 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedUploadedFileRow.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedUploadedFileRow.tsx
@@ -2,7 +2,7 @@ import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@man
import { Download, Eye, FileText } from "lucide-react";
import { isViewable } from "@edr/ui-common";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
export interface PhasedUploadedFileRowProps {
label: string;
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
onDownload,
compact = false,
}: PhasedUploadedFileRowProps) {
- const viewUrl = fileViewUrl(file.id);
- const canPreview = isViewable({ name: file.name, url: viewUrl });
+ const canPreview = isViewable({ name: file.name, url: "" });
return (
}
- onClick={() => onView({ name: file.name, url: viewUrl })}
+ onClick={() =>
+ void fetchViewableFile(file.id, file.name).then(onView)
+ }
>
View
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts
index bbd3acf90..c00091bfc 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts
@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
-// hazardous/reefer flags when the contract allows them. The parser is
+// hazardous/reefer/return flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
+ /** Contract was created WITH_RETURN — offer the empty-return column. */
+ includeReturn?: boolean;
}
export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string;
hazardous: boolean;
reefer: boolean;
+ withReturn: boolean;
}
export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber"
| "vgmTons"
| "hazardous"
- | "reefer";
+ | "reefer"
+ | "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null {
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
+ if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),
+ withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
});
}
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
+ if (opts.includeReturn) headers.push("With Return (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
+ if (opts.includeReturn) row.push("NO");
return row;
});
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts
index a734c4f06..8c3e6f071 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts
@@ -17,12 +17,14 @@ export interface GlShipmentTotal {
/** A normalized view of the form quantities, freight-shape agnostic. */
export interface GlShipmentQuantities {
isContainer: boolean;
- /** Container lines: size + total qty + hazardous/reefer qty. */
+ /** Container lines: size + total qty + hazardous/reefer/return qty. */
containers: Array<{
containerSize: string;
quantity: number;
hazardousQuantity: number;
reeferQuantity: number;
+ /** Containers EDR takes back empty — only on WITH_RETURN contracts. */
+ returnQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
@@ -53,6 +55,7 @@ export function computeGlShipmentTotal(
if (q.isContainer) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
+ let returnTotalQty = 0;
for (const line of q.containers) {
const qty = line.quantity;
@@ -75,6 +78,7 @@ export function computeGlShipmentTotal(
}
hazardTotalQty += line.hazardousQuantity;
reeferTotalQty += line.reeferQuantity;
+ returnTotalQty += line.returnQuantity;
}
if (contract.isHazardous && hazardTotalQty > 0) {
@@ -101,6 +105,21 @@ export function computeGlShipmentTotal(
});
}
}
+ // Empty-container return is a container-only surcharge, priced per returning
+ // container rather than per line (contract-pricing.service emits the
+ // `with_return` rate only for WITH_RETURN contracts).
+ if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
+ const wr = rateFor((i) => i.conditionalOn === "with_return");
+ if (wr) {
+ lines.push({
+ label: wr.label,
+ unitPrice: wr.unitPrice,
+ unit: wr.unit,
+ quantity: returnTotalQty,
+ amount: wr.unitPrice * returnTotalQty,
+ });
+ }
+ }
} else {
const qty = q.bulkQuantity;
const rate =
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
index 371a83ccf..c7893659b 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx
@@ -23,7 +23,7 @@ import {
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer";
import { formatDate, humanize } from "./format";
@@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
- view({
- name: c.fileName ?? humanize(c.code),
- url: fileViewUrl(c.fileId),
- })
+ void fetchViewableFile(
+ c.fileId,
+ c.fileName ?? humanize(c.code),
+ ).then(view)
}
style={{
textDecoration:
@@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
- view({
- name: `Document ${i + 1}`,
- url: fileViewUrl(fileId),
- })
+ void fetchViewableFile(fileId, `Document ${i + 1}`).then(
+ view,
+ )
}
>
Document {i + 1}
@@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
- view({
- name: c.fileName ?? "License document",
- url: fileViewUrl(c.fileId),
- })
+ void fetchViewableFile(
+ c.fileId,
+ c.fileName ?? "License document",
+ ).then(view)
}
style={{
textDecoration:
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/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
index e6c01c26d..1c9d94825 100644
--- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx
@@ -176,6 +176,7 @@ const RuleEngineFormDialog = ({
) {
return false;
}
+ if (field.showIf && !field.showIf(values)) return false;
return true;
}),
[fields, values],
@@ -192,6 +193,22 @@ const RuleEngineFormDialog = ({
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
next.rateUnit = "";
}
+ // The legal yards depend on what the rate is for and which way it runs, so
+ // a leg picked under the old answer is no longer valid — clear it instead
+ // of submitting a pair the API will reject.
+ if (
+ (name === "appliesTo" || name === "tradeDirection") &&
+ "originYardId" in current
+ ) {
+ next.originYardId = "";
+ next.destinationYardId = "";
+ }
+ // Intercity asks for a container type or a bulk cargo type, never both —
+ // switching kind drops whichever the other kind had filled in.
+ if (name === "intercityKind") {
+ next.containerTypeId = "";
+ next.cargoTypeId = "";
+ }
return next;
});
};
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..2cedad1eb 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 { IMPORT_TRAIN_OPTIONS, exportRunFor } 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 export run is fixed by the import run, so it tracks it rather than
+ // being entered by hand (and clears back to empty when the import is cleared).
+ useEffect(() => {
+ setExportTrainNumber(exportRunFor(importTrainNumber));
+ }, [importTrainNumber]);
+
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 import train number (e.g. 8002) — the export run follows it",
variant: "destructive",
});
return;
@@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100}
/>
+ {/* Fixed by the import run — derived, never typed. */}
setExportTrainNumber(e.currentTarget.value)}
- maxLength={20}
- error={
- exportTrainNumber && !isOddNumber(exportTrainNumber)
- ? "Must be numeric and odd"
- : undefined
- }
+ readOnly
+ variant="filled"
/>
- setImportTrainNumber(e.currentTarget.value)}
- maxLength={20}
- error={
- importTrainNumber && !isEvenNumber(importTrainNumber)
- ? "Must be numeric and even"
- : undefined
- }
+ data={IMPORT_TRAIN_OPTIONS}
+ value={importTrainNumber || null}
+ onChange={(value) => setImportTrainNumber(value ?? "")}
+ searchable
+ clearable
/>
{
if (!data) return null;
const removed = new Set(removeIds);
@@ -79,8 +82,11 @@ export default function AdjustConsistModal({
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
const gross = round2(data.totals.cargoTons + tare);
+ const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length;
+ const cap = data.scheduleCapacity;
+ const freeSlots = cap ? wagonCount - cap.allocatedWagons : null;
return {
- wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
+ wagonCount,
tare: round2(tare),
gross,
length: round2(length),
@@ -93,16 +99,33 @@ export default function AdjustConsistModal({
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
overLength:
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
+ slots:
+ cap && freeSlots != null
+ ? {
+ allocated: cap.allocatedWagons,
+ free: freeSlots,
+ pct:
+ wagonCount > 0
+ ? Math.round((cap.allocatedWagons / wagonCount) * 100)
+ : null,
+ isFullNow: cap.bookingWindowStatus === "FULL",
+ willBeFull: freeSlots <= 0,
+ overAllocated: freeSlots < 0,
+ willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0,
+ }
+ : null,
};
}, [data, removeIds, addIds]);
+ const hasChanges = removeIds.length > 0 || addIds.length > 0;
+
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return;
try {
- await adjust.mutateAsync({
+ const result = await adjust.mutateAsync({
scheduleId,
payload: {
...(addIds.length ? { addWagonIds: addIds } : {}),
@@ -114,6 +137,18 @@ export default function AdjustConsistModal({
removeIds.length && addIds.length ? ", " : ""
}${addIds.length ? `${addIds.length} added` : ""}`,
});
+ // Schedule-impact warnings from the API: window reopened / now FULL /
+ // consist trimmed below what bookings already hold.
+ for (const warning of result.warnings ?? []) {
+ toast({
+ title: "Schedule capacity",
+ description: warning,
+ duration: 8000,
+ ...(warning.includes("over capacity")
+ ? { variant: "destructive" as const }
+ : {}),
+ });
+ }
setRemoveIds([]);
setAddIds([]);
} catch (err) {
@@ -169,8 +204,57 @@ export default function AdjustConsistModal({
over={projection?.overLength ?? false}
/>
+ {projection?.slots ? (
+
+ 0
+ ? ` — ${projection.slots.free} free`
+ : projection.slots.free === 0
+ ? " — none free (FULL)"
+ : ""
+ }`}
+ pct={projection.slots.pct}
+ over={projection.slots.overAllocated}
+ />
+
+ ) : null}
+ {projection?.slots?.isFullNow && !hasChanges ? (
+ }>
+ This schedule is FULL — all {projection.wagonCount} wagon slots are
+ taken. You can still edit the train: coupling wagons adds capacity
+ and reopens booking; trimming free wagons keeps it FULL.
+
+ ) : null}
+ {hasChanges && projection?.slots?.overAllocated ? (
+ }>
+ This change leaves {-projection.slots.free} booked wagon(s) without
+ a slot — bookings already hold {projection.slots.allocated} of the{" "}
+ {projection.wagonCount} remaining. You can apply it, but couple
+ wagons back or free bookings before departure.
+
+ ) : null}
+ {hasChanges &&
+ projection?.slots &&
+ !projection.slots.overAllocated &&
+ projection.slots.willBeFull &&
+ !projection.slots.isFullNow ? (
+ }>
+ This change takes the last free wagon slot — the schedule becomes
+ FULL and stops accepting bookings.
+
+ ) : null}
+ {hasChanges && projection?.slots?.willReopen ? (
+ }>
+ This schedule is currently FULL — applying frees{" "}
+ {projection.slots.free} wagon slot(s) and reopens its booking
+ window.
+
+ ) : null}
+
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts
index 3d42c8684..5072ce34a 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts
@@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling';
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
const units: ContainerUnitRow[] = [];
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
- const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
for (let i = 0; i < quantity; i++) {
units.push({
@@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con
label: `${containerType} ${i + 1}/${quantity}`,
grossWeightTons: 25,
sizeFt,
- wagonsPerUnit,
containersPerWagon,
teuSlots: sizeFt >= 40 ? 2 : 1,
});
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
index 164094cd3..160e0a61d 100644
--- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -167,6 +167,7 @@ export const QUERY_KEYS = {
orderList: (resource: RuleEngineResourceSlug | string) =>
["rule-engine", "order-list", resource] as const,
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
+ rateChanges: ["rule-engine", "rate-changes"] as const,
},
OVERVIEW: {
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 87c30044c..c1de9839e 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -344,6 +344,7 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
+ INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings",
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts
new file mode 100644
index 000000000..b7af36639
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts
@@ -0,0 +1,63 @@
+/**
+ * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
+ * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing
+ * an export number fully determines the import one.
+ *
+ * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
+ * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
+ * the convention stays correct here.
+ *
+ * Mirrors RUN_WAGONS/IMPORT_RUN in the API's SeedWagonRunNumbers migration —
+ * keep the two in sync when runs are added or retired.
+ */
+export const TRAIN_RUN_PAIRS: Record = {
+ "8001": "8002",
+ "8101": "8102",
+ "8201": "8202",
+ "8301": "8302",
+ "8401": "8402",
+ "8501": "8502",
+ "8601": "8602",
+ "8701": "8702",
+ "8801": "8802",
+ "8901": "8902",
+ "9001": "9002",
+};
+
+/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */
+const EXPORT_BY_IMPORT: Record = Object.fromEntries(
+ Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]),
+);
+
+/** Selectable export runs (odd), in run order. */
+export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
+ label: run,
+ value: run,
+}));
+
+/** Selectable import runs (even), in run order. */
+export const IMPORT_TRAIN_OPTIONS = Object.values(TRAIN_RUN_PAIRS).map((run) => ({
+ label: run,
+ value: run,
+}));
+
+/**
+ * Options for filtering a list by run — one entry per pair, labelled
+ * "export-import" (8001-8002). The value is the odd EXPORT run, which uniquely
+ * identifies the pair; the API matches a wagon whose export OR import run
+ * equals it, so the whole train's wagons come back.
+ */
+export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map(
+ ([exportRun, importRun]) => ({
+ label: `${exportRun}-${importRun}`,
+ value: exportRun,
+ }),
+);
+
+/** The import run implied by an export run; empty string when unset/unknown. */
+export const importRunFor = (exportRun: unknown): string =>
+ TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
+
+/** The export run implied by an import run; empty string when unset/unknown. */
+export const exportRunFor = (importRun: unknown): string =>
+ EXPORT_BY_IMPORT[String(importRun ?? "")] ?? "";
diff --git a/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts b/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts
new file mode 100644
index 000000000..8047fc822
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/support/supportApi.ts
@@ -0,0 +1,70 @@
+import type {
+ SendSupportMessageDto,
+ SupportConversationDto,
+ SupportConversationListResult,
+ SupportMessageDto,
+} from "@edr/types";
+
+import { api } from "@/auth/http";
+
+export interface ListConversationsParams {
+ search?: string;
+ unreadOnly?: boolean;
+ page?: number;
+ limit?: number;
+}
+
+/**
+ * Backoffice (agent) support-chat REST calls. The backoffice axios `api`
+ * response interceptor already unwraps the `{ success, data }` envelope, so
+ * `.data` here is the payload itself.
+ */
+export const supportApi = {
+ listConversations: async (
+ params: ListConversationsParams = {},
+ ): Promise => {
+ const { data } = await api.get(
+ "/support/agent/conversations",
+ { params },
+ );
+ return data;
+ },
+ listMessages: async (id: string): Promise => {
+ const { data } = await api.get(
+ `/support/agent/conversations/${id}/messages`,
+ );
+ return data;
+ },
+ sendMessage: async (
+ id: string,
+ body: SendSupportMessageDto,
+ ): Promise => {
+ const { data } = await api.post(
+ `/support/agent/conversations/${id}/messages`,
+ body,
+ );
+ return data;
+ },
+ /** Open the thread with a company, or hand back the existing one. */
+ startConversation: async (
+ companyId: string,
+ ): Promise => {
+ const { data } = await api.post(
+ "/support/agent/conversations",
+ { companyId },
+ );
+ return data;
+ },
+ markRead: async (id: string): Promise<{ unreadCount: number }> => {
+ const { data } = await api.post<{ unreadCount: number }>(
+ `/support/agent/conversations/${id}/read`,
+ );
+ return data;
+ },
+ unreadCount: async (): Promise => {
+ const { data } = await api.get<{ unreadCount: number }>(
+ "/support/agent/unread-count",
+ );
+ return data.unreadCount;
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts b/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts
new file mode 100644
index 000000000..da152191e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/support/useSupport.ts
@@ -0,0 +1,71 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { supportApi, type ListConversationsParams } from "./supportApi";
+
+export const SUPPORT_KEY = ["support"] as const;
+export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
+export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
+export const supportMessagesKey = (id: string) =>
+ ["support", "messages", id] as const;
+
+/** Shared inbox: every thread, filterable by unread + company-name search. */
+export function useConversations(params: ListConversationsParams = {}) {
+ return useQuery({
+ queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
+ queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
+ });
+}
+
+export function useMessages(conversationId: string | null) {
+ return useQuery({
+ queryKey: supportMessagesKey(conversationId ?? ""),
+ queryFn: () => supportApi.listMessages(conversationId as string),
+ enabled: !!conversationId,
+ });
+}
+
+export function useSupportUnreadCount(enabled = true) {
+ return useQuery({
+ queryKey: SUPPORT_UNREAD_KEY,
+ queryFn: () => supportApi.unreadCount(),
+ enabled,
+ refetchInterval: 60_000,
+ });
+}
+
+export function useSendMessage(conversationId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: string) =>
+ supportApi.sendMessage(conversationId, { body }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
+ qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
+ },
+ });
+}
+
+/**
+ * Start chatting with a company. Idempotent server-side, so picking a company
+ * that already has a thread just selects it.
+ */
+export function useStartConversation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (companyId: string) => supportApi.startConversation(companyId),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
+ },
+ });
+}
+
+export function useMarkConversationRead() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => supportApi.markRead(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
+ qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
+ },
+ });
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts b/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts
new file mode 100644
index 000000000..574ef7b5c
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/support/useSupportSocket.ts
@@ -0,0 +1,70 @@
+import {
+ SUPPORT_CHAT_WS_EVENTS,
+ SUPPORT_CHAT_WS_NAMESPACE,
+ type SupportConversationDto,
+ type SupportMessageEvent,
+} from "@edr/types";
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef } from "react";
+import { io } from "socket.io-client";
+
+import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
+import { API_BASE_URL } from "@/constants/apiConfig";
+
+import {
+ SUPPORT_CONVERSATIONS_KEY,
+ SUPPORT_UNREAD_KEY,
+ supportMessagesKey,
+} from "./useSupport";
+
+// The socket namespace lives at the server root, not under the `/api` REST
+// prefix — strip a trailing `/api` if the base URL carries one.
+const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
+
+/**
+ * Subscribes the signed-in agent to live support-chat pushes for the whole
+ * shared inbox. Any new message or conversation change refreshes the affected
+ * thread, the inbox list, and the unread badge; `onMessage` fires for toasts.
+ */
+export function useSupportSocket(
+ enabled: boolean,
+ onMessage?: (event: SupportMessageEvent) => void,
+) {
+ const qc = useQueryClient();
+ const onMessageRef = useRef(onMessage);
+ onMessageRef.current = onMessage;
+
+ useEffect(() => {
+ if (!enabled) return;
+ const token = getCookie(AUTH_TOKEN_COOKIE);
+ if (!token) return;
+
+ const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
+ auth: { token },
+ transports: ["websocket"],
+ withCredentials: true,
+ });
+
+ socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
+ qc.invalidateQueries({
+ queryKey: supportMessagesKey(event.message.conversationId),
+ });
+ qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
+ qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
+ onMessageRef.current?.(event);
+ });
+
+ socket.on(
+ SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
+ (_conversation: SupportConversationDto) => {
+ qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
+ qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
+ },
+ );
+
+ return () => {
+ socket.off();
+ socket.disconnect();
+ };
+ }, [enabled, qc]);
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index 5ae7bac72..1fdc611d4 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -7,6 +7,7 @@ import {
ruleEngineService,
type RuleEngineListParams,
type SubmitPriorityRuleChangePayload,
+ type SubmitRateChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
@@ -154,6 +155,37 @@ export const useContainerTypeOptions = (
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
+/** A yard option that remembers its country, so callers can filter by leg. */
+export interface YardOption {
+ label: string;
+ value: string;
+ country: string;
+}
+
+/**
+ * Active yards for the rate form's origin/destination pickers. The country
+ * rides along on each option because which yards are legal depends on the
+ * rate's direction (import starts in Djibouti, export starts in Ethiopia).
+ */
+export const useYardOptions = (enabled = true) =>
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("yards", { activeOnly: true }),
+ queryFn: () => ruleEngineService.listAll("yards"),
+ enabled,
+ select: (rows): YardOption[] =>
+ rows
+ .filter((row) => row.id && row.isActive !== false)
+ .map((row) => {
+ const label = String(row.label ?? "").trim();
+ const code = String(row.code ?? "").trim();
+ return {
+ label: label && code ? `${label} (${code})` : label || code || String(row.id),
+ value: String(row.id),
+ country: String(row.country ?? ""),
+ };
+ }),
+ });
+
/**
* Active wagon-type options for the cargo-type / container-type "Wagon type"
* picker. The FK the selection sets drives train-scheduling wagon resolution.
@@ -318,6 +350,71 @@ export const usePriorityRuleWorkflow = (
return { pending, submit, approve, reject };
};
+/**
+ * Approval workflow for edits to LIVE rates. The live rate keeps its current
+ * value until a change is approved, so the rates list is invalidated on every
+ * outcome — including reject, which restores the row's "no pending" state.
+ */
+export const useRateChangeWorkflow = (
+ enabled: boolean,
+ onErrorMessage?: (message: string) => void,
+) => {
+ const qc = useQueryClient();
+
+ const showError = (err: unknown, fallback: string) => {
+ const raw = (err as { response?: { data?: { message?: string | string[] } } })
+ ?.response?.data?.message;
+ const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback;
+ if (onErrorMessage) onErrorMessage(message);
+ else toast.error(message);
+ };
+
+ const pending = useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges,
+ queryFn: () => ruleEngineService.listRateChanges("PENDING"),
+ enabled,
+ });
+
+ const invalidate = async () => {
+ await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges });
+ await invalidateRuleEngineList(qc, "rates");
+ };
+
+ const submit = useMutation({
+ mutationFn: (payload: SubmitRateChangePayload) =>
+ ruleEngineService.submitRateChange(payload),
+ onSuccess: async () => {
+ toast.success(
+ "Change submitted for approval — the rate keeps its current value until approved",
+ );
+ await invalidate();
+ },
+ onError: (err) => showError(err, "Failed to submit rate change"),
+ });
+
+ const approve = useMutation({
+ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
+ ruleEngineService.approveRateChange(id, decisionNote),
+ onSuccess: async () => {
+ toast.success("Rate change approved — the new rate is now live");
+ await invalidate();
+ },
+ onError: (err) => showError(err, "Failed to approve rate change"),
+ });
+
+ const reject = useMutation({
+ mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
+ ruleEngineService.rejectRateChange(id, decisionNote),
+ onSuccess: async () => {
+ toast.success("Rate change rejected — the rate keeps its current value");
+ await invalidate();
+ },
+ onError: (err) => showError(err, "Failed to reject rate change"),
+ });
+
+ return { pending, submit, approve, reject };
+};
+
export const useRateWorkflow = () => {
const qc = useQueryClient();
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index 9bf9aab6e..98f998bc0 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
}
+/**
+ * Deciding a filed change — a step above `manage`, which only lets a staff
+ * member propose one. Only resources with an approval workflow have it.
+ */
+export function ruleEngineApproveKey(slug: "rates"): string {
+ return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`;
+}
+
+export function canApproveRuleEngineChange(
+ user: AuthUser | null | undefined,
+ slug: "rates",
+): boolean {
+ return hasPermission(user, ruleEngineApproveKey(slug));
+}
+
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,
diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json
index 162014f55..8cf8582b7 100644
--- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json
+++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json
@@ -2669,9 +2669,6 @@
"uploadNew": "Upload New",
"updateExisting": "Update Existing",
"updateComingSoon": "Update existing functionality coming soon",
- "uploadNew": "Upload New",
- "updateExisting": "Update Existing",
- "updateComingSoon": "Update existing functionality coming soon",
"count": "Count",
"posMissing": "Missing 'Positions' or 'Users' sheet.",
"invalidFile": "The uploaded file is empty or invalid.",
@@ -2697,7 +2694,6 @@
"archiveDepartment": "Archive Department",
"deleteDepartment": "Delete Department",
"deleteConfirm": "Delete Department?",
- "delete": "Delete",
"deleteFailed": "Failed to delete department",
"cannotDeleteWithEmployees": "Cannot delete department with assigned employees",
"reassignEmployeesFirst": "Please reassign or remove all employees from this department first.",
@@ -3087,7 +3083,10 @@
"failedToResend": "Failed To Resend Verification Code",
"failedToSendInvitation": "Failed To Send Invitation",
"confirmRemoveEmployee": "Are you sure you want to remove {{name}}? This action cannot be undone.",
- "removeFunctionalityNotImplemented": "Remove functionality will be implemented with proper API integration"
+ "removeFunctionalityNotImplemented": "Remove functionality will be implemented with proper API integration",
+ "sendingPasswordReset": "Sending password reset link...",
+ "passwordResetSent": "Password Reset Link Sent",
+ "passwordResetSentTo": "{{name}} can now set a new password. Any earlier code no longer works."
},
"Resend Invite": "Resend Invite",
"deputy": {
@@ -3305,7 +3304,6 @@
"selectStyleCategory": "Select Style Category",
"customized": "Customized",
"textAlign": "Text Align",
- "pdfPreview": "PDF Preview",
"autoRefreshEnabled": "Auto refresh is enabled",
"manualRefreshEnabled": "Manual refresh",
"openInNewTab": "Open in new tab",
@@ -7053,11 +7051,9 @@
"attach": "Attach",
"selectAttachment": "Select Attachment from DMS",
"search": "Search…",
- "loading": "Loading…",
- "loadingFiles": "Fetching DMS files...",
- "searchFiles": "Search folders and files...",
"loading": "Loading files...",
"loadingFiles": "Fetching DMS files...",
+ "searchFiles": "Search folders and files...",
"retry": "Retry",
"noSearchResults": "No files match your search",
"emptyFolder": "Nothing to show here",
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index 07a6c72cd..30fdcb9c4 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -61,7 +61,6 @@ interface RefContainerType {
name: string;
code: string;
is_reefer?: boolean;
- wagons_per_unit?: number;
}
interface RefContainerGroup {
size: string;
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
index 99f29b985..313ad1836 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx
@@ -63,8 +63,10 @@ import {
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
-import { fileViewUrl } from "@/constants/apiConfig";
-import { downloadBookingFile } from "@/services/files.service";
+import {
+ downloadBookingFile,
+ fetchViewableFile,
+} from "@/services/files.service";
import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types";
@@ -396,10 +398,9 @@ export default function ContractRequestDetailPage() {
radius="lg"
leftSection={ }
onClick={() =>
- handleViewFile({
- ...contractPdf,
- url: fileViewUrl(contractPdf.id),
- })
+ void fetchViewableFile(contractPdf.id, contractPdf.name).then(
+ view,
+ )
}
>
View contract
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index dc682f1c7..b03a54835 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Alert,
Anchor,
Badge,
Box,
@@ -22,6 +23,7 @@ import {
Download,
Eye,
FileText,
+ Hourglass,
IdCard,
LayoutGrid,
Package,
@@ -52,7 +54,10 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
-import { fileViewUrl } from "@/constants/apiConfig";
+import {
+ downloadBookingFile,
+ fetchViewableFile,
+} from "@/services/files.service";
import { api } from "@/services/api";
import type {
CompanyProfile,
@@ -60,6 +65,7 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
+import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
@@ -166,6 +172,13 @@ export default function CustomerDetailPage() {
);
const paidCurrency = payments[0]?.currency ?? "ETB";
+ // The company row is created on the wizard's first click, so a draft reaches
+ // this page with a placeholder name/TIN. `stillOnboarding` drives the banner
+ // and badge; `canReview` gates the approve/reject buttons and mirrors the
+ // API's rule exactly, so no button is offered that the server would reject.
+ const stillOnboarding = company ? isOnboardingDraft(company) : false;
+ const canReview = company ? hasSubmittedOnboarding(company) : true;
+
const profileColumns: ColumnDef[] = useMemo(
() => [
{
@@ -202,13 +215,7 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
- onClick={() =>
- view({
- name: f.name,
- url: fileViewUrl(f.id),
- mimeType: f.mimeType,
- })
- }
+ onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
>
@@ -217,13 +224,7 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
- onClick={() =>
- view({
- name: f.name,
- url: fileViewUrl(f.id),
- mimeType: f.mimeType,
- })
- }
+ onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
style={{
maxWidth: 170,
textAlign: "left",
@@ -273,11 +274,12 @@ export default function CustomerDetailPage() {
),
},
],
- [view],
+ [view, canReview],
);
const bookingColumns: ColumnDef[] = useMemo(
@@ -401,18 +403,19 @@ export default function CustomerDetailPage() {
aria-label="View"
data-stop-row-click
onClick={() =>
- view({
- name: row.original.name,
- url: fileViewUrl(row.original.id),
- mimeType: row.original.mimeType,
- })
+ void fetchViewableFile(row.original.id, row.original.name).then(
+ view,
+ )
}
>
+ void downloadBookingFile(row.original.id, row.original.name)
+ }
variant="subtle"
color="gray"
aria-label="Download"
@@ -602,7 +605,13 @@ export default function CustomerDetailPage() {
meta={
-
+ {stillOnboarding ? (
+
+ Onboarding in progress
+
+ ) : (
+
+ )}
}
@@ -631,6 +640,21 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
+ {stillOnboarding && (
+ }
+ title="This customer hasn't submitted their application yet"
+ >
+ They're still filling in the onboarding wizard, so the details
+ below are an unfinished draft — the company name and TIN are
+ placeholders until they reach those steps. Role profiles become
+ reviewable once the application is submitted.
+
+ )}
+
p.status === "pending",
- ).length,
+ // A draft's profiles are all `pending` by construction, which
+ // would read as a review backlog that doesn't exist yet.
+ label: stillOnboarding
+ ? "Awaiting submission"
+ : "Pending approval",
+ value: stillOnboarding
+ ? "—"
+ : company.companyProfiles.filter(
+ (p) => p.status === "pending",
+ ).length,
icon: IdCard,
color: "yellow",
},
@@ -801,11 +831,7 @@ export default function CustomerDetailPage() {
size="sm"
lineClamp={1}
onClick={() =>
- view({
- name: doc.name,
- url: fileViewUrl(doc.id),
- mimeType: doc.mimeType,
- })
+ void fetchViewableFile(doc.id, doc.name).then(view)
}
>
{doc.name}
@@ -831,18 +857,17 @@ export default function CustomerDetailPage() {
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
- view({
- name: doc.name,
- url: fileViewUrl(doc.id),
- mimeType: doc.mimeType,
- })
+ void fetchViewableFile(doc.id, doc.name).then(view)
}
>
+ void downloadBookingFile(doc.id, doc.name)
+ }
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
@@ -942,11 +967,7 @@ export default function CustomerDetailPage() {
component="button"
type="button"
onClick={() =>
- view({
- name: f.name,
- url: fileViewUrl(f.id),
- mimeType: f.mimeType,
- })
+ void fetchViewableFile(f.id, f.name).then(view)
}
size="xs"
style={{
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
index aff353055..89325ae79 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
@@ -16,6 +16,7 @@ import {
Building2,
CheckCircle2,
Clock,
+ Hourglass,
Mail,
Phone,
RefreshCw,
@@ -36,6 +37,7 @@ import {
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
+import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
@@ -43,22 +45,39 @@ import {
type ColumnDef,
} from "@edr/ui-common";
+/**
+ * The list's segmented views. "Pending approval" means submitted-and-awaiting-
+ * review, so it excludes drafts — a company row exists from the onboarding
+ * wizard's first click and would otherwise pad the review queue. Those drafts
+ * get their own view instead of disappearing, so staff can still chase them.
+ */
+type CustomerView = "all" | "pending" | "onboarding" | "active";
+
+const VIEW_FILTERS: Record<
+ CustomerView,
+ { status?: CompanyStatus; onboardingCompleted?: boolean }
+> = {
+ all: {},
+ pending: { status: "pending", onboardingCompleted: true },
+ onboarding: { onboardingCompleted: false },
+ active: { status: "active" },
+};
+
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
- // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
- const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
+ const [view, setView] = useState("all");
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
- status: statusFilter || undefined,
+ ...VIEW_FILTERS[view],
}),
- [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
+ [pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
);
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
@@ -114,6 +133,17 @@ export default function CustomersPage() {
id: "status",
header: "Status",
cell: ({ row }) => {
+ // A draft's profiles are all `pending` by construction, so the
+ // "N pending" review hint would be a lie until they submit.
+ if (isOnboardingDraft(row.original)) {
+ return (
+
+
+ Onboarding
+
+
+ );
+ }
const pending = (row.original.companyProfiles ?? []).filter(
(p) => p.status === "pending",
).length;
@@ -206,6 +236,12 @@ export default function CustomersPage() {
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
+ {
+ label: "Onboarding",
+ value: stats?.onboarding ?? "—",
+ icon: Hourglass,
+ color: "gray",
+ },
{
label: "Blacklisted",
value: stats?.blacklisted ?? "—",
@@ -243,14 +279,15 @@ export default function CustomersPage() {
{
- setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
+ setView(v as CustomerView);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
+ { label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx
index b9599d23d..939a5a931 100644
--- a/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx
@@ -39,6 +39,18 @@ interface DraftField extends CreateFileUploadFieldDto {
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
+/**
+ * Extensions offered as checkboxes. Mirrors DOC_EXTENSIONS in the API's
+ * file-upload-settings seeder — the only formats the document flows accept.
+ *
+ * The API validates `allowedExtensions` as plain strings, so free text let
+ * typos ("pd") through silently and the field then rejected every real upload.
+ * A fixed list makes that unrepresentable.
+ */
+const FILE_EXTENSION_OPTIONS = ["pdf", "jpg", "jpeg", "png"] as const;
+
+const KNOWN_EXTENSIONS = new Set(FILE_EXTENSION_OPTIONS);
+
function makeEmptyDraft(idx: number): DraftField {
return {
key: nextKey(),
@@ -93,13 +105,19 @@ export default function ManageFileUploadFieldsDialog({
}),
);
- const updateExtensions = (i: number, raw: string) => {
- const list = raw
- .split(",")
- .map((s) => s.trim().toLowerCase().replace(/^\./, ""))
- .filter(Boolean);
- update(i, { allowedExtensions: list });
- };
+ const toggleExtension = (i: number, ext: string, checked: boolean) =>
+ setFields((prev) =>
+ prev.map((f, idx) => {
+ if (idx !== i) return f;
+ const current = f.allowedExtensions;
+ if (checked) {
+ return current.includes(ext)
+ ? f
+ : { ...f, allowedExtensions: [...current, ext] };
+ }
+ return { ...f, allowedExtensions: current.filter((e) => e !== ext) };
+ }),
+ );
const remove = (i: number) =>
setFields((prev) => prev.filter((_, idx) => idx !== i));
@@ -214,7 +232,9 @@ export default function ManageFileUploadFieldsDialog({
index={i}
total={fields.length}
onChange={(patch) => update(i, patch)}
- onChangeExtensions={(raw) => updateExtensions(i, raw)}
+ onToggleExtension={(ext, checked) =>
+ toggleExtension(i, ext, checked)
+ }
onMove={(dir) => move(i, dir)}
onRemove={() => remove(i)}
/>
@@ -258,7 +278,7 @@ function FieldEditor({
index,
total,
onChange,
- onChangeExtensions,
+ onToggleExtension,
onMove,
onRemove,
}: {
@@ -266,13 +286,23 @@ function FieldEditor({
index: number;
total: number;
onChange: (patch: Partial) => void;
- onChangeExtensions: (raw: string) => void;
+ onToggleExtension: (ext: string, checked: boolean) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const minFiles = getMinFiles(field);
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
+ // A field saved before this list existed can hold anything the old free-text
+ // box accepted (e.g. the typo "pd"). Show those alongside the standard ones so
+ // they stay visible and removable instead of silently vanishing on save.
+ const extensionChoices = [
+ ...FILE_EXTENSION_OPTIONS,
+ ...field.allowedExtensions.filter(
+ (ext) => !KNOWN_EXTENSIONS.has(ext),
+ ),
+ ];
+
return (
@@ -348,16 +378,33 @@ function FieldEditor({
-
Allowed Extensions
-
onChangeExtensions(e.target.value)}
- placeholder="pdf, docx, jpg"
- className="font-mono"
- />
-
- Comma-separated, no leading dot.
-
+
Allowed Extensions *
+
+ {extensionChoices.map((ext) => (
+
+ onToggleExtension(ext, e.target.checked)}
+ className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
+ />
+ {ext}
+ {!KNOWN_EXTENSIONS.has(ext) ? (
+ (unrecognised)
+ ) : null}
+
+ ))}
+
+ {field.allowedExtensions.length === 0 ? (
+
Pick at least one extension.
+ ) : (
+
+ Uploads are rejected unless the file matches one of these.
+
+ )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx
index c97d7c418..3633b1942 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx
@@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
+import {
+ downloadBookingFile,
+ fetchViewableFile,
+} from "@/services/files.service";
import { useToast } from "@/hooks/use-toast";
const fmtDate = (iso?: string | null) => {
@@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
- // /files/:id is a public inline-serving route; open directly for preview/download.
- const fileUrl = (fileId: string, download = false) =>
- `${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
-
return (
@@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => {
{fmtDate(doc.createdAt)}
- window.open(fileUrl(doc.id), "_blank")}>
+
+ void fetchViewableFile(doc.id, doc.name).then((f) =>
+ window.open(f.url, "_blank"),
+ )
+ }
+ >
- window.open(fileUrl(doc.id, true), "_blank")}>
+ void downloadBookingFile(doc.id, doc.name)}
+ >
{
const status = listFilterValues.status;
const currentYardId = listFilterValues.currentYardId;
const availability = listFilterValues.availability;
+ const trainNumber = listFilterValues.trainNumber;
if (status && status !== "ALL") {
(filters as { status?: string }).status = status;
}
@@ -68,6 +69,9 @@ const FleetResourcePage = () => {
if (availability && availability !== "ALL") {
(filters as { availability?: string }).availability = availability;
}
+ if (trainNumber && trainNumber !== "ALL") {
+ (filters as { trainNumber?: string }).trainNumber = trainNumber;
+ }
if (slug !== "locomotives" && search.trim()) {
filters.search = search.trim();
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts
index 82c1eaa58..caa0ee6c4 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts
@@ -1,5 +1,10 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
+import {
+ IMPORT_TRAIN_OPTIONS,
+ TRAIN_RUN_FILTER_OPTIONS,
+ exportRunFor,
+} from "@/constants/trainRuns";
import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles";
import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers";
@@ -40,6 +45,20 @@ export interface FleetResourceColumn {
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
+ /**
+ * Read-only field whose value is computed from the other fields rather than
+ * typed. Rendered non-editable and recomputed on every change, so the stored
+ * form value for this field is never trusted — the function is the source of
+ * truth at both render and submit.
+ */
+ derivedValue?: (values: Record) => string;
+ /**
+ * Field can be emptied back to NULL. Empty values are normally dropped from
+ * the payload (so a PATCH leaves them untouched); a clearable field instead
+ * submits an explicit `null`, which is what actually unsets the column. Also
+ * renders a clear button on a `select`.
+ */
+ clearable?: boolean;
/**
* Field is owned by the Fayda identity — populated only by verification and
* never hand-edited. Rendered disabled in the form.
@@ -53,7 +72,7 @@ export interface FleetFormFieldDef extends FormFieldDef {
}
export interface FleetListFilterDef {
- key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId";
+ key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId" | "trainNumber";
label: string;
options?: Array<{ value: string; label: string }>;
allLabel?: string;
@@ -118,6 +137,7 @@ const WAGON_STATUS_OPTIONS = [
+
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
@@ -258,19 +278,58 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
allLabel: "All yards",
dynamicOptions: "yards",
},
+ {
+ key: "trainNumber",
+ label: "Train number",
+ allLabel: "All trains",
+ options: TRAIN_RUN_FILTER_OPTIONS,
+ },
],
cardTitleKey: "wagonNumber",
cardSubtitleKey: "currentYard",
- searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
+ searchKeys: [
+ "wagonNumber",
+ "wagonTypeId",
+ "trainId",
+ "exportTrainNumber",
+ "importTrainNumber",
+ "status",
+ "currentYardId",
+ ],
columns: [
// Tare weight and payload capacity are not wagon columns — they belong to the
// wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages).
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
+ // Unset on a wagon that is not on a run — renders as a dimmed dash.
+ { id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" },
+ { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
+ // Run numbers are optional — a wagon sits in the fleet unassigned to any
+ // run until an operator picks an import run. The export run is fixed by
+ // that choice, so it is derived rather than typed.
+ {
+ name: "exportTrainNumber",
+ label: "Export train number",
+ type: "text",
+ description: "Odd — Ethiopia → Djibouti runs",
+ placeholder: "e.g. 8001",
+ derivedValue: (values) => exportRunFor(values.importTrainNumber),
+ // Follows the import run to NULL when that is cleared.
+ clearable: true,
+ },
+ {
+ name: "importTrainNumber",
+ label: "Import train number",
+ type: "select",
+ description: "Even — Djibouti → Ethiopia runs",
+ placeholder: "e.g. 8002",
+ options: IMPORT_TRAIN_OPTIONS,
+ clearable: true,
+ },
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
@@ -278,6 +337,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
+ exportTrainNumber: "",
+ importTrainNumber: "",
wagonNumber: "",
wagonTypeId: "",
currentYardId: "",
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx
new file mode 100644
index 000000000..6156762a0
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx
@@ -0,0 +1,247 @@
+import { useState } from "react";
+import {
+ Badge,
+ Button,
+ Card,
+ Collapse,
+ Group,
+ Stack,
+ Text,
+ Textarea,
+ Tooltip,
+} from "@mantine/core";
+import type { UseMutationResult } from "@tanstack/react-query";
+import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react";
+
+import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
+
+/** Field labels for the diff — anything not listed falls back to the raw key. */
+const FIELD_LABELS: Record = {
+ rateValue: "Rate",
+ currency: "Currency",
+ rateUnit: "Unit",
+ appliesTo: "Applies to",
+ trigger: "Trigger",
+ tradeDirection: "Direction",
+ containerTypeId: "Container type",
+ cargoTypeId: "Cargo type",
+};
+
+const fmtDateTime = (iso: string) =>
+ new Date(iso).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+
+const fmtValue = (field: string, value: unknown): string => {
+ if (value === null || value === undefined || value === "") return "—";
+ if (field === "rateValue") {
+ const num = Number(value);
+ return Number.isNaN(num) ? String(value) : num.toLocaleString();
+ }
+ return String(value).replace(/_/g, " ");
+};
+
+/** "Ocean freight · 40HC" — what rate this change targets. */
+const rateSummary = (r: RateChangeRequest): string => {
+ const rate = (r.rate ?? {}) as Record;
+ const parts = [
+ rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null,
+ rate.appliesTo ? String(rate.appliesTo) : null,
+ rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null,
+ ].filter(Boolean);
+ return parts.join(" · ") || "Rate";
+};
+
+/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
+const headline = (r: RateChangeRequest): string | null => {
+ if (!("rateValue" in r.payload)) return null;
+ const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? "");
+ const before = fmtValue("rateValue", r.previousValues.rateValue);
+ const after = fmtValue("rateValue", r.payload.rateValue);
+ return `${before} → ${after}${currency ? ` ${currency}` : ""}`;
+};
+
+type Decide = UseMutationResult<
+ RateChangeRequest,
+ unknown,
+ { id: string; decisionNote?: string }
+>;
+
+interface RateApprovalsSectionProps {
+ requests: RateChangeRequest[];
+ /** Whether this user holds the rates approve permission. */
+ canDecide: boolean;
+ approve: Decide;
+ reject: Decide;
+}
+
+/**
+ * Pending edits to LIVE rates. Each row is a before→after diff: the left value
+ * is what pricing charges right now and keeps charging until someone approves.
+ * Rendered above the rates table.
+ */
+const RateApprovalsSection = ({
+ requests,
+ canDecide,
+ approve,
+ reject,
+}: RateApprovalsSectionProps) => {
+ const [openId, setOpenId] = useState(null);
+ const [notes, setNotes] = useState>({});
+
+ if (requests.length === 0) return null;
+
+ const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
+
+ return (
+
+
+
+ Pending rate changes
+
+ {requests.length}
+
+
+
+ Each rate below still charges its current value. Nothing changes until approved.
+
+
+
+ {requests.map((r) => {
+ const isOpen = openId === r.id;
+ const fields = Object.keys(r.payload);
+ const summaryLine = headline(r);
+ // Only the row being decided shows a spinner — the mutation's
+ // isPending is shared across every row.
+ const busy = decidingId === r.id;
+
+ return (
+
+
+
+
+
+ update
+
+
+ {rateSummary(r)}
+
+
+
+ {summaryLine ? (
+
+
+ {fmtValue("rateValue", r.previousValues.rateValue)}
+
+
+
+ {fmtValue("rateValue", r.payload.rateValue)}
+
+
+ {String(
+ r.payload.currency ??
+ r.previousValues.currency ??
+ (r.rate as Record | undefined)?.currency ??
+ "",
+ )}
+
+
+ ) : null}
+
+
+
+ Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
+ {fields.length === 1 ? "field" : "fields"} changed
+
+ setOpenId(isOpen ? null : r.id)}
+ >
+ {isOpen ? "Hide details" : "See all changes"}
+
+
+
+
+ {canDecide ? (
+
+ }
+ loading={busy && reject.isPending}
+ disabled={busy && approve.isPending}
+ onClick={() =>
+ reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
+ }
+ >
+ Reject
+
+ }
+ loading={busy && approve.isPending}
+ disabled={busy && reject.isPending}
+ onClick={() =>
+ approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
+ }
+ >
+ Approve & apply
+
+
+ ) : (
+
+
+ Awaiting approver
+
+
+ )}
+
+
+
+
+ {fields.map((field) => (
+
+
+ {FIELD_LABELS[field] ?? field}
+
+
+ {fmtValue(field, r.previousValues[field])}
+
+
+
+ {fmtValue(field, r.payload[field])}
+
+
+ ))}
+ {canDecide ? (
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default RateApprovalsSection;
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index af8b65a50..db7190ed7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -1,5 +1,8 @@
import { useAuth } from "@/auth/useAuth";
-import { canAccessRuleEngineResource } from "@/lib/permissions";
+import {
+ canAccessRuleEngineResource,
+ canApproveRuleEngineChange,
+} from "@/lib/permissions";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
@@ -11,14 +14,16 @@ import {
Modal,
Stack,
Text,
+ Tooltip,
} from "@mantine/core";
-import { Plus } from "lucide-react";
+import { Clock, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
+import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
@@ -36,7 +41,10 @@ import {
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
+ useYardOptions,
+ type YardOption,
usePriorityRuleWorkflow,
+ useRateChangeWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -51,6 +59,7 @@ import {
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
+import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
DataTable,
@@ -65,6 +74,41 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
return undefined;
};
+/**
+ * Which yards may sit at one end of the leg a base-freight rate prices.
+ *
+ * The railway sells three shapes and each pins the countries: an import lands
+ * at a Djibouti port and rails inland, an export is the reverse, and intercity
+ * stays inside Ethiopia. Narrowing the dropdown is what stops an import rate
+ * from being configured Ethiopia → Ethiopia — the API rejects that too, but
+ * the admin should never be offered it. Non-base-freight rates carry no leg,
+ * so they get nothing.
+ */
+const yardOptionsForLegEnd = (
+ yards: YardOption[],
+ values: Record,
+ end: "origin" | "destination",
+): { label: string; value: string }[] => {
+ const appliesTo = String(values.appliesTo ?? "");
+ let country: string | undefined;
+ if (appliesTo === "INTERCITY") {
+ country = "Ethiopia";
+ } else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
+ const direction = String(values.tradeDirection ?? "");
+ // Direction is what decides the countries, so offer nothing until it is set
+ // rather than defaulting to one and letting it read as a real choice.
+ if (direction !== "IMPORT" && direction !== "EXPORT") return [];
+ const startsInEthiopia = direction === "EXPORT";
+ country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
+ ? "Ethiopia"
+ : "Djibouti";
+ }
+ if (!country) return [];
+ return yards
+ .filter((yard) => yard.country === country)
+ .map(({ label, value }) => ({ label, value }));
+};
+
const RuleEngineResourcePage = () => {
const { user } = useAuth();
const { resource: resourceSlug } = useParams<{ resource: string }>();
@@ -145,6 +189,23 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules",
);
+ // A LIVE rate is what pricing charges, so editing one files a change request
+ // instead of mutating: the rate keeps its current value until an approver
+ // applies the change. DRAFT rates still edit directly.
+ const isRates = config?.slug === "rates";
+ const [rateError, setRateError] = useState(null);
+ const rateChangeWorkflow = useRateChangeWorkflow(
+ Boolean(isRates && canView),
+ setRateError,
+ );
+ const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
+ /** rateId → its pending change, for the row badge. */
+ const pendingByRateId = useMemo(() => {
+ const map = new Map();
+ for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
+ return map;
+ }, [rateChangeWorkflow.pending.data]);
+
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table. Validation errors (range collision,
// gap, ceiling) surface in a modal so the text is impossible to miss.
@@ -181,6 +242,11 @@ const RuleEngineResourcePage = () => {
useLiveRateOptions(usesLiveRateField);
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
+ const usesYardField = Boolean(
+ config?.formFields.some((f) => f.name === "originYardId"),
+ );
+ const { data: yardOptions, isLoading: yardOptionsLoading } =
+ useYardOptions(usesYardField);
// Full rule list backing the auto-filled "min wagon count": the next range
// always continues the chain for the selected type (per currency), so the
@@ -255,9 +321,22 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
+ // Each end of the leg only offers yards in the country that end of the
+ // trade actually sits in, so an import can't be configured as if it
+ // started inland. Resolved per keystroke because the legal set changes
+ // with the direction the admin picks.
+ if (field.name === "originYardId" || field.name === "destinationYardId") {
+ const end = field.name === "originYardId" ? "origin" : "destination";
+ return {
+ ...field,
+ type: "select" as const,
+ optionsFromValues: (values: Record) =>
+ yardOptionsForLegEnd(yardOptions ?? [], values, end),
+ };
+ }
return field;
});
- }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
+ }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -306,7 +385,27 @@ const RuleEngineResourcePage = () => {
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
- cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
+ cell: ({ row }) => {
+ const cell = formatCell(row.original[col.accessorKey], col.format);
+ // On the rate column, show the proposed value under the live one — the
+ // live value stays the headline because it is what still gets charged.
+ if (!isRates || col.accessorKey !== "rateValue") return cell;
+ const change = pendingByRateId.get(String(row.original.id));
+ if (!change || change.payload.rateValue === undefined) return cell;
+ return (
+
+ {cell}
+
+
+
+
+ {Number(change.payload.rateValue).toLocaleString()} pending
+
+
+
+
+ );
+ },
}));
base.push({
@@ -357,6 +456,8 @@ const RuleEngineResourcePage = () => {
}, [
canManage,
config,
+ isRates,
+ pendingByRateId,
submit,
handleApproveRate,
handleMoveOrder,
@@ -400,6 +501,21 @@ const RuleEngineResourcePage = () => {
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
+ // Editing a LIVE rate files a change request — the rate keeps charging
+ // its current value until an approver applies it. DRAFT rates fall
+ // through to the normal update below.
+ if (editing?.id && editing.status === "LIVE") {
+ rateChangeWorkflow.submit.mutate(
+ { rateId: String(editing.id), update: payload },
+ {
+ onSuccess: () => {
+ setFormOpen(false);
+ setEditing(null);
+ },
+ },
+ );
+ return;
+ }
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
@@ -483,6 +599,31 @@ const RuleEngineResourcePage = () => {
/>
) : null}
+ {isRates ? (
+
+ ) : null}
+
+ setRateError(null)}
+ title="Cannot save rate change"
+ centered
+ >
+
+ {rateError}
+
+
+ setRateError(null)}>
+ Close
+
+
+
+
setPriorityError(null)}
@@ -623,7 +764,8 @@ const RuleEngineResourcePage = () => {
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading) ||
- (usesWagonTypeField && wagonTypeOptionsLoading)
+ (usesWagonTypeField && wagonTypeOptionsLoading) ||
+ (usesYardField && yardOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index c7c76b91b..0dc30ea1b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -47,6 +47,13 @@ export interface FormFieldDef {
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
+ /**
+ * Show this field only when the predicate accepts the live form values — for
+ * visibility that depends on more than one field, which `showWhen` cannot
+ * express (the intercity container/bulk pickers hang off both `appliesTo`
+ * and `intercityKind`). Combines with showWhen/hideWhen: all must pass.
+ */
+ showIf?: (values: Record) => boolean;
/**
* Select options computed from other fields' current values. When set, the
* form resolves the option list at render time from the live form state
@@ -145,6 +152,21 @@ const RATE_TRIGGERS = [
{ label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
];
+/**
+ * Intercity runs inside Ethiopia and can carry either boxes or bulk, but the
+ * two price differently. The admin says which up front and the form then asks
+ * for the matching scope field — this choice is not stored on the rate itself;
+ * the API reads container-vs-bulk back off whichever scope field was filled.
+ */
+const INTERCITY_KINDS = [
+ { label: "Container", value: "CONTAINER" },
+ { label: "Bulk", value: "BULK" },
+];
+
+/** True when the rate being edited is base rail freight, which is priced per leg. */
+const isBaseFreightRate = (values: Record) =>
+ ["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
+
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -384,7 +406,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Max wagon count",
type: "number",
required: true,
- description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
+ description: "No upper limit — must be at least the min wagon count",
},
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
@@ -477,6 +499,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "country", header: "Country", accessorKey: "country" },
+ {
+ id: "hasFacility",
+ header: "Facility",
+ accessorKey: "hasFacility",
+ format: "boolean",
+ },
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
activeColumn,
],
@@ -489,6 +517,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true,
options: YARD_COUNTRIES,
},
+ {
+ name: "hasFacility",
+ label: "Has load/unload facility",
+ type: "boolean",
+ description:
+ "This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -531,6 +566,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
+ // Base freight is priced per leg, so the route is what tells two otherwise
+ // identical rates apart. Surcharges have no leg and render as "—".
+ { id: "originYard", header: "From", accessorKey: "originYard", format: "entityLabel" },
+ {
+ id: "destinationYard",
+ header: "To",
+ accessorKey: "destinationYard",
+ format: "entityLabel",
+ },
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
@@ -564,23 +608,62 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
},
- // ── Container type — Container & Intercity ────────────────────────────
+ // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
+ {
+ name: "intercityKind",
+ label: "Cargo type",
+ type: "select",
+ required: true,
+ options: INTERCITY_KINDS,
+ placeholder: "Is this rate for containers or bulk?",
+ description: "Intercity prices containers and bulk differently — pick which this covers.",
+ showWhen: { field: "appliesTo", equals: ["INTERCITY"] },
+ // Not a stored column: an existing rate records its kind in the rateType
+ // the API derived (INTERCITY_BULK / INTERCITY_CONTAINER).
+ getInitialValue: (record) =>
+ record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
+ },
+ // ── Container type — Container freight, and container-kind intercity ──
{
name: "containerTypeId",
label: "Container type",
type: "select",
optional: true,
placeholder: "Select container type (optional)",
- showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] },
+ showIf: (v) =>
+ v.appliesTo === "CONTAINER" ||
+ (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
},
- // ── Bulk cargo (leaf commodity) — Bulk & Intercity ───────────────────
+ // ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
optional: true,
placeholder: "Select bulk commodity (optional)",
- showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
+ showIf: (v) =>
+ v.appliesTo === "BULK" ||
+ (v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
+ },
+ // ── The leg this rate prices — base freight only ──────────────────────
+ // Options are narrowed to the countries the direction allows (import
+ // starts in Djibouti, export in Ethiopia, intercity stays in Ethiopia);
+ // see RuleEngineResourcePage, which injects the yard lists.
+ {
+ name: "originYardId",
+ label: "Origin yard",
+ type: "select",
+ required: true,
+ placeholder: "Where the leg starts",
+ showIf: isBaseFreightRate,
+ },
+ {
+ name: "destinationYardId",
+ label: "Destination yard",
+ type: "select",
+ required: true,
+ placeholder: "Where the leg ends",
+ showIf: isBaseFreightRate,
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
index f387fc8dc..9357e1967 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
@@ -1,20 +1,14 @@
/**
* Client mirror of the backend's contiguous-range rules for priority configs
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
- * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
- * range always starts at the lowest uncovered wagon count. The backend
- * re-validates on submit AND on approval; this only drives the form prefill.
+ * currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
+ * range always starts at the lowest uncovered wagon count. There is no upper
+ * ceiling. The backend re-validates on submit AND on approval; this only
+ * drives the form prefill.
*/
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
-/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
-export const PRIORITY_RANGE_CAPS: Record = {
- WAGON: 50,
- CURRENCY: 35,
- CUSTOMS: 15,
-};
-
export interface PriorityRangeRule {
id?: unknown;
type?: unknown;
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
maxWagonCount?: unknown;
}
+const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
+
/**
* Where the next range for `type` (+`currency`) must start, excluding
- * `excludeId` (the rule being edited). Null when the chain already covers
- * 1..cap — no further rule fits.
+ * `excludeId` (the rule being edited). Null only when `type` is not yet a
+ * known priority rule type — the chain itself is unbounded, so a next start
+ * always exists.
*/
export function nextPriorityRangeStart(
rules: PriorityRangeRule[],
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
currency: string | null | undefined,
excludeId?: string,
): number | null {
- const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
- if (!cap) return null;
+ if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
const scoped = rules
.filter(
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1);
}
- return next > cap ? null : next;
+ return next;
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx b/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx
new file mode 100644
index 000000000..861b3bee7
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/support/SupportInboxPage.tsx
@@ -0,0 +1,459 @@
+import {
+ SupportAuthorRole,
+ type SupportConversationDto,
+ type SupportMessageDto,
+} from "@edr/types";
+import {
+ ActionIcon,
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ Group,
+ Loader,
+ Modal,
+ Paper,
+ ScrollArea,
+ SegmentedControl,
+ Select,
+ Stack,
+ Text,
+ Textarea,
+ TextInput,
+ ThemeIcon,
+} from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import toast from "react-hot-toast";
+
+import {
+ useConversations,
+ useMarkConversationRead,
+ useMessages,
+ useSendMessage,
+ useStartConversation,
+} from "@/features/support/useSupport";
+import { useSupportSocket } from "@/features/support/useSupportSocket";
+import { customersService } from "@/services/customers.service";
+
+type ReadFilter = "ALL" | "UNREAD";
+
+function formatTime(iso?: string | null): string {
+ if (!iso) return "";
+ const d = new Date(iso);
+ const now = new Date();
+ const sameDay = d.toDateString() === now.toDateString();
+ return sameDay
+ ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
+ : d.toLocaleDateString([], { month: "short", day: "numeric" });
+}
+
+export default function SupportInboxPage() {
+ const [readFilter, setReadFilter] = useState("ALL");
+ const [search, setSearch] = useState("");
+ const [selectedId, setSelectedId] = useState(null);
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const { data, isLoading } = useConversations({
+ search,
+ unreadOnly: readFilter === "UNREAD",
+ });
+ const items = data?.items ?? [];
+
+ useSupportSocket(true, (event) => {
+ if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
+ toast(
+ `New message from ${event.conversation.companyName ?? "a customer"}`,
+ { icon: "💬" },
+ );
+ }
+ });
+
+ const selected = useMemo(
+ () => items.find((c) => c.id === selectedId) ?? null,
+ [items, selectedId],
+ );
+
+ return (
+
+
+
+
+
+
+
+ Customer Support
+
+
+ Shared inbox — chat with customers in real time
+
+
+
+
+
+ {/* ── Conversation list ── */}
+
+
+ }
+ onClick={() => setPickerOpen(true)}
+ mb="sm"
+ >
+ New chat
+
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ radius="md"
+ mb="sm"
+ />
+ setReadFilter(v as ReadFilter)}
+ data={[
+ { label: "All", value: "ALL" },
+ { label: "Unread", value: "UNREAD" },
+ ]}
+ />
+
+
+ {isLoading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+ {readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
+
+ ) : (
+ items.map((c) => (
+ setSelectedId(c.id)}
+ />
+ ))
+ )}
+
+
+
+ {/* ── Thread ── */}
+
+ {selected ? (
+
+ ) : (
+
+
+
+
+ Select a conversation, or start a new chat.
+
+ )}
+
+
+
+ setPickerOpen(false)}
+ onStarted={(id) => {
+ setSelectedId(id);
+ setPickerOpen(false);
+ }}
+ />
+
+ );
+}
+
+/**
+ * Pick a company to chat with. Starting is idempotent server-side, so choosing a
+ * company that already has a thread simply selects it rather than erroring.
+ */
+function CompanyPicker({
+ opened,
+ onClose,
+ onStarted,
+}: {
+ opened: boolean;
+ onClose: () => void;
+ onStarted: (conversationId: string) => void;
+}) {
+ const [companyId, setCompanyId] = useState(null);
+ const start = useStartConversation();
+
+ const { data: companies, isLoading } = useQuery({
+ queryKey: ["companies", "list"],
+ queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
+ enabled: opened,
+ });
+
+ const options = useMemo(
+ () =>
+ (companies?.items ?? []).map((c) => ({
+ value: c.id,
+ label: c.name || c.email || c.tin || c.id,
+ })),
+ [companies],
+ );
+
+ const submit = async () => {
+ if (!companyId) return;
+ const conversation = await start.mutateAsync(companyId);
+ setCompanyId(null);
+ onStarted(conversation.id);
+ };
+
+ return (
+
+
+
+
+ Start chatting
+
+
+
+ );
+}
+
+function InboxRow({
+ c,
+ active,
+ onClick,
+}: {
+ c: SupportConversationDto;
+ active: boolean;
+ onClick: () => void;
+}) {
+ const unread = c.unreadCount > 0;
+ return (
+
+
+
+
+
+ {c.companyName ?? "Unknown company"}
+
+
+
+ {formatTime(c.lastMessageAt)}
+
+
+
+
+ {c.lastMessageAuthorRole === SupportAuthorRole.AGENT ? "You: " : ""}
+ {c.lastMessagePreview ?? "No messages yet"}
+
+ {unread && (
+
+ {c.unreadCount}
+
+ )}
+
+
+ );
+}
+
+function ConversationThread({
+ conversation,
+}: {
+ conversation: SupportConversationDto;
+}) {
+ const { data: messages, isLoading } = useMessages(conversation.id);
+ const send = useSendMessage(conversation.id);
+ const markRead = useMarkConversationRead();
+ const [draft, setDraft] = useState("");
+ const viewport = useRef(null);
+
+ useEffect(() => {
+ markRead.mutate(conversation.id);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [conversation.id, messages?.length]);
+
+ useEffect(() => {
+ viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
+ }, [messages?.length, conversation.id]);
+
+ const submit = async () => {
+ const body = draft.trim();
+ if (!body) return;
+ setDraft("");
+ await send.mutateAsync(body);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {conversation.companyName ?? "Unknown company"}
+
+
+
+
+ {/* Messages */}
+
+ {isLoading ? (
+
+
+
+ ) : (messages ?? []).length === 0 ? (
+
+ No messages yet — say hello.
+
+ ) : (
+
+ {(messages ?? []).map((m) => (
+
+ ))}
+
+ )}
+
+
+ {/* Composer */}
+
+
+
+
+
+ );
+}
+
+function AgentBubble({ m }: { m: SupportMessageDto }) {
+ const mine = m.authorRole === SupportAuthorRole.AGENT;
+ return (
+
+ {!mine && (
+
+
+
+ )}
+
+
+ {mine ? m.authorName || "You" : m.authorName || "Customer"}
+
+
+
+ {m.body}
+
+
+
+ {formatTime(m.createdAt)}
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
index f4e65266f..378bb5aad 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
@@ -255,6 +255,8 @@ export default function TrainBuilderDetailPage() {
void withToast(
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index 33c4535ce..640b9bde1 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -52,7 +52,6 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
-import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -103,7 +102,6 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState(null);
const [containerPlacements, setContainerPlacements] = useState([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
- const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -976,18 +974,6 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
) : null}
- {schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
- }
- onClick={() => setAdjustConsistOpen(true)}
- >
- Adjust consist
-
- ) : null}
{gatepassApplies ? (
gatepassSecured ? (
) : null}
- setAdjustConsistOpen(false)}
- />
-
-
+ {/*
-
- setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
- }
- clampBehavior="none"
- allowNegative={false}
- allowDecimal
- min={1}
- disabled={loading}
- />
-
- setForm((current) => ({ ...current, maxTrainWeightTons: value }))
- }
- clampBehavior="none"
- allowNegative={false}
- allowDecimal
- min={1}
- disabled={loading}
- />
-
- setForm((current) => ({
- ...current,
- max20ftContainerWeightTons: value,
- }))
- }
- clampBehavior="none"
- allowNegative={false}
- allowDecimal
- min={0.001}
- disabled={loading}
- />
-
- setForm((current) => ({
- ...current,
- max20ftPairWeightDiffTons: value,
- }))
- }
- clampBehavior="none"
- allowNegative={false}
- allowDecimal
- min={0}
- disabled={loading}
- />
-
+ */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx
new file mode 100644
index 000000000..6a45be0f2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx
@@ -0,0 +1,291 @@
+import { useMemo, useState } from "react";
+import {
+ Alert,
+ Badge,
+ Card,
+ Center,
+ Group,
+ Loader,
+ SimpleGrid,
+ Table,
+ Tabs,
+ Text,
+ Tooltip,
+} from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
+
+import { PageContainer, PageHeader } from "@/components/page";
+import { api } from "@/services/api";
+import type { IntercityRideAlongRow } from "@/types/trainScheduling";
+
+/**
+ * Intercity cargo across every train.
+ *
+ * Intercity bookings never get their own train — they ride whichever
+ * import/export train passes through their corridor — so the work is spread over
+ * other people's schedules. This is the one place it's all visible.
+ */
+
+const fmtTons = (t: number | null) => (t == null ? "—" : `${t} t`);
+
+/** A booking can only be worked where the train actually is. */
+const atOrigin = (r: IntercityRideAlongRow) =>
+ Boolean(r.trainAtYardId) && r.trainAtYardId === r.originYardId;
+const atDestination = (r: IntercityRideAlongRow) =>
+ Boolean(r.trainAtYardId) && r.trainAtYardId === r.destinationYardId;
+
+const isWaiting = (r: IntercityRideAlongRow) =>
+ !r.loadedAt && r.status !== "IN_TRANSIT" && r.status !== "COMPLETED";
+const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
+const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
+
+/** Yards with no equipment can never load/unload — surface it before the train arrives. */
+function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
+ if (!yard) return — ;
+ if (has) return {yard} ;
+ return (
+
+
+
+
+ {yard}
+
+
+
+ );
+}
+
+function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
+ if (rows.length === 0) {
+ return (
+
+ Nothing here.
+
+ );
+ }
+ return (
+
+
+
+
+ Booking
+ Customer
+ Load at
+ Unload at
+ Train
+ Weight
+ GRN
+ Status
+
+
+
+ {rows.map((r) => (
+
+
+
+ {r.reference ?? r.bookingId.slice(0, 8)}
+
+
+ {r.customer ?? "—"}
+
+
+
+ {atOrigin(r) && isWaiting(r) && (
+
+ train here
+
+ )}
+
+
+
+
+
+ {atDestination(r) && isRiding(r) && (
+
+ train here
+
+ )}
+
+
+
+ {r.trainNumber ? (
+
+
+ {r.trainNumber}
+
+ ) : (
+
+ not on a train
+
+ )}
+
+
+ {fmtTons(r.weightTons)}
+
+
+
+ {r.grnNumber ?? "—"}
+
+
+
+
+ {r.status}
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function Stat({
+ icon,
+ label,
+ value,
+ color,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: React.ReactNode;
+ color?: string;
+}) {
+ return (
+
+
+ {icon}
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+}
+
+export default function IntercityPage() {
+ const [tab, setTab] = useState("waiting");
+ const { data: rows = [], isLoading } = useQuery(
+ api.trainScheduling.intercityBookings.queryOptions({ input: undefined }),
+ );
+
+ const waiting = useMemo(() => rows.filter(isWaiting), [rows]);
+ const riding = useMemo(() => rows.filter(isRiding), [rows]);
+ const done = useMemo(() => rows.filter(isDone), [rows]);
+ // A booking whose end has no equipment is stuck until someone flags the yard.
+ const blocked = useMemo(
+ () =>
+ rows.filter(
+ (r) => !isDone(r) && (!r.originHasFacility || !r.destinationHasFacility),
+ ),
+ [rows],
+ );
+
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+ }
+ label="Waiting to load"
+ value={waiting.length}
+ />
+ } label="On a train" value={riding.length} />
+ } label="Completed" value={done.length} />
+ }
+ label="No facility"
+ value={blocked.length}
+ color={blocked.length > 0 ? "red" : undefined}
+ />
+
+
+ {blocked.length > 0 && (
+ }
+ title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
+ mb="md"
+ >
+ Their origin or destination yard has no load/unload facility. Mark the yard as
+ a facility in Configuration → Yards, or the cargo can never be worked there.
+
+ )}
+
+
+ setTab(v ?? "waiting")}>
+
+
+ {waiting.length}
+
+ }
+ >
+ Waiting to load
+
+
+ {riding.length}
+
+ }
+ >
+ On a train
+
+
+ {done.length}
+
+ }
+ >
+ Completed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading and unloading happen on the train's schedule page, where the ride-along
+ panel confirms the train is at the yard.
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index 7a2a478c1..fe323a472 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service";
import {
trainBuilderService,
type AdjustConsistPayload,
+ type AdjustConsistResult,
type AvailableTrain,
type BuildTrainPayload,
type BuiltTrainListFilters,
@@ -338,7 +339,7 @@ export const api = {
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
- ScheduleConsist
+ AdjustConsistResult
>(
"train-scheduling",
"adjust-consist",
@@ -701,6 +702,16 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
+ intercityBookings: endpoint<
+ void,
+ import("@/types/trainScheduling").IntercityRideAlongRow[]
+ >(
+ "train-scheduling",
+ "intercity-bookings",
+ () => trainSchedulingService.listIntercityBookings(),
+ () => ["train-scheduling", "intercity-bookings"],
+ ),
+
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts
index d9b4369c3..038b5a86d 100644
--- a/apps/edr-freight-web/backoffice/src/services/files.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts
@@ -24,3 +24,20 @@ export async function downloadBookingFile(
a.click();
URL.revokeObjectURL(url);
}
+
+/**
+ * GET /files/:id is authenticated (global JwtGuard) — raw browser loads
+ * ( /
{viewer}
@@ -110,6 +105,7 @@ function DutyAdvicePanel({
}) {
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
+ const noticeFile = dutyAdvice.noticeFile;
return (
@@ -124,16 +120,16 @@ function DutyAdvicePanel({
? ` · Payment code: ${dutyAdvice.declarationSerial}`
: null}
- {dutyAdvice.noticeFile ? (
+ {noticeFile ? (
void downloadStoredFile(noticeFile.id, noticeFile.name)}
size="sm"
>
- Download duty notice ({dutyAdvice.noticeFile.name})
+ Download duty notice ({noticeFile.name})
) : null}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
index 1e86564f0..d372f6e37 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx
@@ -23,7 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
@@ -289,15 +289,25 @@ export function DraftBookingView({
action={
isUploaded && !allowReplace ? (
void downloadStoredFile(file.id, file.name)
+ : undefined
+ }
icon={ }
/>
) : (
<>
{isUploaded && (
+ void downloadStoredFile(
+ file.id,
+ file.name,
+ )
+ : undefined
}
icon={ }
/>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
index fe7f58e62..e64d50c68 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
@@ -15,7 +15,7 @@ import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
@@ -102,7 +102,6 @@ function FileRow({
last?: boolean;
onView: (f: { name: string; url: string }) => void;
}) {
- const viewUrl = file ? fileViewUrl(file.id) : null;
return (
{pill}
- {file && viewUrl && isViewable({ name: file.name, url: viewUrl }) && (
+ {file && isViewable({ name: file.name, url: "" }) && (
}
- onClick={() => onView({ name: file.name, url: viewUrl })}
+ onClick={() => void fetchViewableFile(file.id, file.name).then(onView)}
+ />
+ )}
+ {file && (
+ }
+ onClick={() => void downloadStoredFile(file.id, file.name)}
/>
)}
- {file && } />}
);
}
@@ -475,12 +479,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
files={clearance!.workflowFiles ?? []}
tradeDirection={booking.tradeDirection}
onView={(f) => view(f)}
- onDownload={({ id, name }) => {
- const a = document.createElement("a");
- a.href = fileViewUrl(id, true);
- a.download = name;
- a.click();
- }}
+ onDownload={({ id, name }) => void downloadStoredFile(id, name)}
/>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
index cce837bc6..c101d0661 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
@@ -1,5 +1,5 @@
import { api } from "@/services/api";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { downloadStoredFile } from "@/services/files.service";
import type { CreateBookingPayload } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -966,9 +966,13 @@ export default function EditBookingPage() {
{isUploaded && !selected && (
+ void downloadStoredFile(
+ uploadedFile.id,
+ uploadedFile.name,
+ )
: undefined
}
icon={ }
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
index 4edfa73b7..78e6d67b1 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
@@ -22,7 +22,7 @@ import {
ClearanceAdHocUploadSection,
} from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
@@ -167,21 +167,23 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
{isViewable({
name: doc.file.name,
- url: fileViewUrl(doc.file.id),
+ url: "",
}) && (
}
onClick={() =>
- view({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(
+ doc.file!.id,
+ doc.file!.name,
+ ).then(view)
}
/>
)}
}
+ onClick={() =>
+ void downloadStoredFile(doc.file!.id, doc.file!.name)
+ }
/>
) : (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
index 2de959e5f..1fa76447c 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx
@@ -434,6 +434,70 @@ export function ToggleRow({
);
}
+/**
+ * One numbered toggle per container unit in the line — tap units to mark how
+ * many are hazardous/refrigerated/returning (2 hazardous → toggle 2 units on).
+ * Selection fills from unit 1: tapping unit N selects 1..N, tapping a selected
+ * unit N keeps 1..N-1 — the count is always derived, never free-typed, so it
+ * can't exceed the line quantity.
+ */
+export function UnitCountToggles({
+ total,
+ value,
+ onChange,
+ label,
+ activeBg,
+ activeBorder,
+ activeColor,
+}: {
+ total: number;
+ value: string;
+ onChange: (v: string) => void;
+ label: string;
+ activeBg: string;
+ activeBorder: string;
+ activeColor: string;
+}) {
+ const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
+
+ return (
+
+
+ {label} · {count}/{total} selected
+
+
+ {Array.from({ length: total }, (_, i) => {
+ const selected = i < count;
+ return (
+ onChange(String(selected ? i : i + 1))}
+ className="rounded-lg"
+ style={{
+ minWidth: 40,
+ padding: "6px 10px",
+ fontSize: 12,
+ fontWeight: 700,
+ cursor: "pointer",
+ border: `1.5px solid ${selected ? activeBorder : BORDER}`,
+ background: selected ? activeBg : "#fff",
+ color: selected ? activeColor : MUTED,
+ transition:
+ "background 120ms ease, border-color 120ms ease, color 120ms ease",
+ }}
+ >
+ #{i + 1}
+
+ );
+ })}
+
+
+ );
+}
+
interface AsyncComboboxOption {
value: string;
label: string;
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index b9cf743fa..734b5e997 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -18,6 +18,7 @@ import {
StepHeader,
StepLabel,
ToggleRow,
+ UnitCountToggles,
} from "./shared";
type BookingForm = UseFormReturn<
@@ -35,70 +36,6 @@ const blockNegative = (event: KeyboardEvent) => {
if (event.key === "-") event.preventDefault();
};
-/**
- * One numbered toggle per container unit in the line — tap units to mark how
- * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
- * fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
- * keeps 1..N-1 — the count is always derived, never free-typed, so it can't
- * exceed the line quantity.
- */
-function UnitCountToggles({
- total,
- value,
- onChange,
- label,
- activeBg,
- activeBorder,
- activeColor,
-}: {
- total: number;
- value: string;
- onChange: (v: string) => void;
- label: string;
- activeBg: string;
- activeBorder: string;
- activeColor: string;
-}) {
- const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
-
- return (
-
-
- {label} · {count}/{total} selected
-
-
- {Array.from({ length: total }, (_, i) => {
- const selected = i < count;
- return (
- onChange(String(selected ? i : i + 1))}
- className="rounded-lg"
- style={{
- minWidth: 40,
- padding: "6px 10px",
- fontSize: 12,
- fontWeight: 700,
- cursor: "pointer",
- border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
- background: selected ? activeBg : "#fff",
- color: selected ? activeColor : "#6B7C8E",
- transition:
- "background 120ms ease, border-color 120ms ease, color 120ms ease",
- }}
- >
- #{i + 1}
-
- );
- })}
-
-
- );
-}
-
export function Step5CargoDetails({
form,
referenceData,
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx
index 1f0d60410..e0908f71b 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx
@@ -3,7 +3,7 @@ import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import { IconSquare } from "../BookingDetailPage/components/Documents";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { downloadStoredFile } from "@/services/files.service";
import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow";
@@ -70,8 +70,8 @@ export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
}
+ onClick={() => void downloadStoredFile(file.id, file.name)}
/>
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
index 67b3fead8..1516d0b71 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx
@@ -22,7 +22,7 @@ import {
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import {
ClearanceAdHocUploadSection,
type AdHocDoc,
@@ -259,21 +259,23 @@ export function ContractClearancePanel({
{isViewable({
name: doc.file.name,
- url: fileViewUrl(doc.file.id),
+ url: "",
}) && (
}
onClick={() =>
- view({
- name: doc.file!.name,
- url: fileViewUrl(doc.file!.id),
- })
+ void fetchViewableFile(
+ doc.file!.id,
+ doc.file!.name,
+ ).then(view)
}
/>
)}
}
+ onClick={() =>
+ void downloadStoredFile(doc.file!.id, doc.file!.name)
+ }
/>
) : (
@@ -294,12 +296,7 @@ export function ContractClearancePanel({
files={clearance?.workflowFiles ?? []}
title="Customs workflow documents"
onView={(f) => view(f)}
- onDownload={({ id, name }) => {
- const a = document.createElement("a");
- a.href = fileViewUrl(id, true);
- a.download = name;
- a.click();
- }}
+ onDownload={({ id, name }) => void downloadStoredFile(id, name)}
/>
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx
index 26eceab8e..99583327b 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx
@@ -9,16 +9,13 @@ import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { contractsService } from "@/services/contracts.service";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { BORDER, INK } from "./contract-ui";
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
function downloadWorkflowFile({ id, name }: { id: string; name: string }) {
- const a = document.createElement("a");
- a.href = fileViewUrl(id, true);
- a.download = name;
- a.click();
+ void downloadStoredFile(id, name);
}
export function ContractClearanceWorkflowBanner({
@@ -191,9 +188,14 @@ function DutyAdvicePanel({
variant="light"
color="orange"
leftSection={ }
- component="a"
- href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
- download={dutyAdvice.noticeFile.name}
+ component="button"
+ type="button"
+ onClick={() =>
+ void downloadStoredFile(
+ dutyAdvice.noticeFile!.id,
+ dutyAdvice.noticeFile!.name,
+ )
+ }
>
Download duty notice
@@ -202,10 +204,10 @@ function DutyAdvicePanel({
variant="subtle"
color="gray"
onClick={() =>
- onPreview({
- name: dutyAdvice.noticeFile!.name,
- url: fileViewUrl(dutyAdvice.noticeFile!.id),
- })
+ void fetchViewableFile(
+ dutyAdvice.noticeFile!.id,
+ dutyAdvice.noticeFile!.name,
+ ).then(onPreview)
}
>
Preview notice
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
index 1b5a29e2a..21594c5ca 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
@@ -57,7 +57,7 @@ import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit";
@@ -450,11 +450,9 @@ export default function ContractDetailPage() {
size="md"
leftSection={ }
onClick={() =>
- view({
- name: contractPdf.name,
- url: fileViewUrl(contractPdf.id),
- mimeType: contractPdf.mimeType,
- })
+ void fetchViewableFile(contractPdf.id, contractPdf.name).then(
+ view,
+ )
}
>
View contract
@@ -1184,10 +1182,7 @@ export default function ContractDetailPage() {
onView={view}
onDownload={async (f) => {
try {
- const a = document.createElement("a");
- a.href = fileViewUrl(f.id, true);
- a.download = f.name;
- a.click();
+ await downloadStoredFile(f.id, f.name);
} catch {
toast.error("Could not download file.");
}
@@ -1749,7 +1744,7 @@ function DocFileRow({
const { ext, color } = fileTypeChip(file.name, file.mimeType);
const viewable = isViewable({
name: file.name,
- url: fileViewUrl(file.id),
+ url: "",
mimeType: file.mimeType,
});
return (
@@ -1804,19 +1799,16 @@ function DocFileRow({
radius="md"
leftSection={ }
onClick={() =>
- onView({
- name: file.name,
- url: fileViewUrl(file.id),
- mimeType: file.mimeType,
- })
+ void fetchViewableFile(file.id, file.name).then(onView)
}
>
View
)}
void downloadStoredFile(file.id, file.name)}
variant="default"
size="xs"
radius="md"
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
index 3d515f3ca..ea83c8163 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
@@ -1,4 +1,11 @@
-import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type ReactNode,
+} from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -31,10 +38,12 @@ import {
ChevronLeft,
FileDown,
FileUp,
+ Flame,
MapPin,
Package,
Receipt,
Repeat,
+ Snowflake,
X,
} from "lucide-react";
@@ -50,6 +59,8 @@ import {
StepCard,
StepHeader,
StepLabel,
+ ToggleRow,
+ UnitCountToggles,
fieldStyles,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -1140,6 +1151,7 @@ function CargoStep({
allowedSizes: sizes,
includeHazardous: contract.isHazardous ?? false,
includeReefer: contract.isReefer ?? false,
+ includeReturn: contract.equipmentReturn === "WITH_RETURN",
};
const handleImportFile = async (file: File | null) => {
@@ -1174,8 +1186,7 @@ function CargoStep({
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
- returnQuantity:
- current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
+ returnQuantity: String(imported.filter((r) => r.withReturn).length),
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -1530,6 +1541,72 @@ function ContainerLineEditor({
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
};
+ // Lowering the line quantity must pull every cargo-handling count back within
+ // it, or a stale count silently exceeds the line and fails validation on a
+ // field the customer can no longer see a cause for.
+ const clampHandlingCounts = (qty: number) => {
+ (["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
+ (key) => {
+ const path = `containers.${index}.${key}` as const;
+ const current = Number(form.getValues(path) || 0);
+ if (current > qty)
+ form.setValue(path, String(Math.max(0, qty)), {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ },
+ );
+ };
+
+ /** Switch state is derived from the count — a line is hazardous iff qty > 0. */
+ const handlingToggle = (
+ key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
+ opts: {
+ icon: ReactNode;
+ iconBg: string;
+ iconColor: string;
+ title: string;
+ description: string;
+ pickLabel: string;
+ activeBg: string;
+ activeBorder: string;
+ activeColor: string;
+ },
+ ) => (
+ (
+ 0}
+ onChange={(on) => field.onChange(on ? "1" : "0")}
+ >
+
+
+ {fieldState.error?.message ? (
+
+ {fieldState.error.message}
+
+ ) : null}
+
+
+ )}
+ />
+ );
+
return (
{size} containers
-
+
{
field.onChange(e.currentTarget.value);
- syncUnits(Number(e.currentTarget.value || 0));
+ const qty = Number(e.currentTarget.value || 0);
+ syncUnits(qty);
+ clampHandlingCounts(qty);
}}
/>
)}
/>
- {isHazardous && (
- (
-
- )}
- />
- )}
- {isReefer && (
- (
-
- )}
- />
- )}
- {withReturnService && (
- (
-
- )}
- />
- )}
-
+
+
+ {/* Cargo handling — only the services this contract was created with are
+ offered, since the server rejects quantities for the others. Each
+ switch reveals a bounded picker: tap the containers it applies to. */}
+ {(isHazardous || isReefer || withReturnService) && quantity > 0 && (
+ <>
+ Cargo handling
+
+ {isHazardous &&
+ handlingToggle("hazardousQuantity", {
+ icon: ,
+ iconBg: "#FBEAE7",
+ iconColor: "#C0392B",
+ title: "Hazardous",
+ description: "Some of these containers carry hazardous cargo.",
+ pickLabel: "Tap the hazardous containers",
+ activeBg: "#FBEAE7",
+ activeBorder: "#E4A69B",
+ activeColor: "#C0392B",
+ })}
+ {isReefer &&
+ handlingToggle("reeferQuantity", {
+ icon: ,
+ iconBg: "#E9F0F8",
+ iconColor: "#2E5B96",
+ title: "Refrigerated",
+ description: "Some of these containers need reefer transport.",
+ pickLabel: "Tap the refrigerated containers",
+ activeBg: "#E9F0F8",
+ activeBorder: "#A9C2E0",
+ activeColor: "#2E5B96",
+ })}
+ {withReturnService &&
+ handlingToggle("returnQuantity", {
+ icon: ,
+ iconBg: "#ECF6F1",
+ iconColor: "#0A6F4D",
+ title: "With return",
+ description: "Some of these containers come back to EDR empty.",
+ pickLabel: "Tap the containers EDR returns",
+ activeBg: "#ECF6F1",
+ activeBorder: "#A9D6C2",
+ activeColor: "#0A6F4D",
+ })}
+
+ >
+ )}
Per-container details
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
index e5a41121c..ee67237f5 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
@@ -4,7 +4,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
// Brand palette (mirrors the booking form's shared constants).
export const INK = "#10202F";
@@ -275,11 +275,14 @@ export function ContractDocButton({
return (
{
+ onClick?.(e);
+ void fetchViewableFile(file.id, file.name).then((f) =>
+ window.open(f.url, "_blank"),
+ );
+ }}
aria-label="Open contract document"
style={{
display: "flex",
@@ -287,6 +290,8 @@ export function ContractDocButton({
justifyContent: "center",
width: 34,
height: 34,
+ padding: 0,
+ cursor: "pointer",
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: "#FFFFFF",
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx
index 649cb8320..4d966e010 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx
@@ -8,7 +8,7 @@ import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import type { CompanyDocument } from "@/services/companies.service";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { downloadStoredFile } from "@/services/files.service";
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
import { BORDER, GREEN, INK } from "../contract-ui";
@@ -181,8 +181,9 @@ export function ContractDocsEditor({
)}
void downloadStoredFile(file.id, file.name)}
variant="default"
size="compact-xs"
radius="md"
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx
index d633a6265..7e3b06695 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx
@@ -16,7 +16,8 @@ import type {
} from "react-hook-form";
// The contract wizard reuses the booking wizard's premium card chrome verbatim
-// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles).
+// (OptionCard / StepCard / StepHeader / AlertBox / StepLabel / fieldStyles),
+// plus the cargo-handling switch + tap-to-count pair used per container line.
export {
AlertBox,
fieldStyles,
@@ -25,6 +26,8 @@ export {
StepCard,
StepHeader,
StepLabel,
+ ToggleRow,
+ UnitCountToggles,
} from "@/pages/bookings/new-booking-form/shared";
import {
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/container-excel.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/container-excel.ts
index bbd3acf90..c00091bfc 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/container-excel.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/container-excel.ts
@@ -2,7 +2,7 @@ import * as XLSX from "xlsx";
// Excel import for container shipments: one spreadsheet row per physical
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
-// hazardous/reefer flags when the contract allows them. The parser is
+// hazardous/reefer/return flags when the contract allows them. The parser is
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
// partial import can never silently drop containers.
@@ -14,6 +14,8 @@ export interface ContainerExcelOptions {
allowedSizes: string[];
includeHazardous: boolean;
includeReefer: boolean;
+ /** Contract was created WITH_RETURN — offer the empty-return column. */
+ includeReturn?: boolean;
}
export interface ImportedContainerRow {
@@ -23,6 +25,7 @@ export interface ImportedContainerRow {
vgmTons: string;
hazardous: boolean;
reefer: boolean;
+ withReturn: boolean;
}
export interface ContainerExcelResult {
@@ -36,7 +39,8 @@ type ColumnKey =
| "sealNumber"
| "vgmTons"
| "hazardous"
- | "reefer";
+ | "reefer"
+ | "withReturn";
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
function headerKey(raw: string): ColumnKey | null {
@@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null {
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
if (h.includes("hazard")) return "hazardous";
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
+ if (h.includes("return")) return "withReturn";
// After the more specific matches: "Container Number", "Container No", …
if (h.includes("container") || h.includes("number")) return "containerNumber";
return null;
@@ -159,6 +164,7 @@ export async function parseContainerExcel(
vgmTons: vgmRaw,
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
reefer: opts.includeReefer && parseFlag(cell("reefer")),
+ withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")),
});
}
@@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
+ if (opts.includeReturn) headers.push("With Return (YES/NO)");
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
const sampleRows = sizes.map((size, i) => {
@@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
];
if (opts.includeHazardous) row.push("NO");
if (opts.includeReefer) row.push("NO");
+ if (opts.includeReturn) row.push("NO");
return row;
});
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.test.ts
new file mode 100644
index 000000000..e1b4a7064
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from "vitest";
+import type { Freight } from "@edr/types";
+import { computeShipmentTotal } from "./total";
+import type { ShipmentFormValues } from "./schema";
+
+// The with_return surcharge is contract-gated and priced per returning
+// container; it was previously omitted from the estimate entirely.
+const contract = (over: Partial = {}) =>
+ ({
+ freightType: "CONTAINER",
+ paymentCurrency: "ETB",
+ isHazardous: true,
+ isReefer: true,
+ equipmentReturn: "WITH_RETURN",
+ pricingBreakdown: {
+ currency: "ETB",
+ lineItems: [
+ {
+ label: "40ft container",
+ unit: "per_container",
+ unitPrice: 100,
+ containerSize: "40ft",
+ },
+ { label: "Hazardous", unit: "per_container", unitPrice: 10, conditionalOn: "is_hazardous" },
+ { label: "Reefer", unit: "per_container", unitPrice: 20, conditionalOn: "is_reefer" },
+ { label: "Empty return", unit: "per_container", unitPrice: 30, conditionalOn: "with_return" },
+ ],
+ },
+ ...over,
+ }) as unknown as Freight.IContract;
+
+const values = (over: Record = {}) =>
+ ({
+ containers: [
+ {
+ containerSize: "40ft",
+ quantity: "10",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "0",
+ units: [],
+ },
+ ],
+ cargoWeightTons: "",
+ itemCount: "",
+ bulkHazardousQuantity: "0",
+ bulkReeferQuantity: "0",
+ ...over,
+ }) as unknown as ShipmentFormValues;
+
+const line = (t: ReturnType, label: string) =>
+ t.lines.find((l) => l.label === label);
+
+describe("computeShipmentTotal — with_return surcharge", () => {
+ it("charges the return surcharge per returning container", () => {
+ const t = computeShipmentTotal(
+ contract(),
+ values({
+ containers: [
+ {
+ containerSize: "40ft",
+ quantity: "10",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "4",
+ units: [],
+ },
+ ],
+ }),
+ );
+ expect(line(t, "Empty return")).toMatchObject({ quantity: 4, amount: 120 });
+ expect(t.total).toBe(100 * 10 + 30 * 4);
+ });
+
+ it("omits it when no container returns", () => {
+ const t = computeShipmentTotal(contract(), values());
+ expect(line(t, "Empty return")).toBeUndefined();
+ expect(t.total).toBe(1000);
+ });
+
+ it("omits it when the contract is not WITH_RETURN", () => {
+ const t = computeShipmentTotal(
+ contract({ equipmentReturn: "WITHOUT_RETURN" } as Partial),
+ values({
+ containers: [
+ {
+ containerSize: "40ft",
+ quantity: "10",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "4",
+ units: [],
+ },
+ ],
+ }),
+ );
+ expect(line(t, "Empty return")).toBeUndefined();
+ expect(t.total).toBe(1000);
+ });
+
+ it("sums return counts across container lines and stacks with haz/reefer", () => {
+ const t = computeShipmentTotal(
+ contract(),
+ values({
+ containers: [
+ {
+ containerSize: "40ft",
+ quantity: "10",
+ hazardousQuantity: "2",
+ reeferQuantity: "3",
+ returnQuantity: "4",
+ units: [],
+ },
+ {
+ containerSize: "40ft",
+ quantity: "5",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "1",
+ units: [],
+ },
+ ],
+ }),
+ );
+ expect(line(t, "Empty return")).toMatchObject({ quantity: 5, amount: 150 });
+ expect(line(t, "Hazardous")).toMatchObject({ quantity: 2, amount: 20 });
+ expect(line(t, "Reefer")).toMatchObject({ quantity: 3, amount: 60 });
+ expect(t.total).toBe(100 * 10 + 100 * 5 + 20 + 60 + 150);
+ });
+
+ it("does not double-count the base rate against the return surcharge", () => {
+ const t = computeShipmentTotal(
+ contract(),
+ values({
+ containers: [
+ {
+ containerSize: "40ft",
+ quantity: "2",
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ returnQuantity: "2",
+ units: [],
+ },
+ ],
+ }),
+ );
+ expect(line(t, "40ft container")).toMatchObject({ quantity: 2, amount: 200 });
+ expect(t.total).toBe(200 + 60);
+ });
+});
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts
index 501da7ed2..d0ed44629 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts
@@ -38,6 +38,7 @@ export function computeShipmentTotal(
if (isContainer) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
+ let returnTotalQty = 0;
for (const line of values.containers) {
const qty = Number(line.quantity || 0);
@@ -60,6 +61,7 @@ export function computeShipmentTotal(
}
hazardTotalQty += Number(line.hazardousQuantity || 0);
reeferTotalQty += Number(line.reeferQuantity || 0);
+ returnTotalQty += Number(line.returnQuantity || 0);
}
if (contract.isHazardous && hazardTotalQty > 0) {
@@ -86,6 +88,21 @@ export function computeShipmentTotal(
});
}
}
+ // Empty-container return is a container-only surcharge, priced per returning
+ // container rather than per line (contract-pricing.service emits the
+ // `with_return` rate only for WITH_RETURN contracts).
+ if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) {
+ const wr = rateFor((i) => i.conditionalOn === "with_return");
+ if (wr) {
+ lines.push({
+ label: wr.label,
+ unitPrice: wr.unitPrice,
+ unit: wr.unit,
+ quantity: returnTotalQty,
+ amount: wr.unitPrice * returnTotalQty,
+ });
+ }
+ }
} else {
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate =
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
index 17c7b87ab..d23362337 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx
@@ -1,4 +1,4 @@
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import {
companiesService,
@@ -113,9 +113,11 @@ export default function TabDocuments({
{ name: string; url: string; size?: number; mimeType?: string | null }[]
> = {};
for (const doc of docsQuery.data ?? []) {
+ // GET /api/files/:id is JWT-guarded, so no raw URL is stored here — the
+ // file id rides in `url` and onViewFile resolves it to a blob URL.
(map[doc.code] ??= []).push({
name: doc.name,
- url: fileViewUrl(doc.id),
+ url: doc.id,
size: doc.size,
mimeType: doc.mimeType,
});
@@ -199,7 +201,7 @@ export default function TabDocuments({
errors={fieldErrors}
uploadedKeys={uploadedKeys}
existingFiles={existingFilesByKey}
- onViewFile={view}
+ onViewFile={(f) => void fetchViewableFile(f.url, f.name).then(view)}
/>
)}
@@ -439,11 +441,7 @@ function ProfileLicenseRow({
size="sm"
fw={600}
onClick={() =>
- onViewFile({
- name: f.name,
- url: fileViewUrl(f.id),
- mimeType: f.mimeType,
- })
+ void fetchViewableFile(f.id, f.name).then(onViewFile)
}
style={{
textAlign: "left",
diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
index 401070911..9df4079cf 100644
--- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
+++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx
@@ -33,7 +33,7 @@ import {
} from "@mantine/core";
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
import { api } from "@/services/api";
-import { fileViewUrl } from "@/constants/apiConfig";
+import { fetchViewableFile } from "@/services/files.service";
import {
companiesService,
type LicenseFile,
@@ -528,11 +528,7 @@ function LetterRow({
size="sm"
fw={600}
onClick={() =>
- onViewFile({
- name: file.name,
- url: fileViewUrl(file.id),
- mimeType: file.mimeType,
- })
+ void fetchViewableFile(file.id, file.name).then(onViewFile)
}
style={{
textAlign: "left",
diff --git a/apps/edr-freight-web/portal/src/services/files.service.ts b/apps/edr-freight-web/portal/src/services/files.service.ts
new file mode 100644
index 000000000..314e185e9
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/services/files.service.ts
@@ -0,0 +1,43 @@
+import { client } from "@/utils/api";
+
+/**
+ * GET /api/files/:id is authenticated (global JwtGuard) — raw browser loads
+ * ( /
+
+
Check-in Cutoff (minutes before departure)
+
+
+ Booking closes and check-in ends this many minutes before each stop's departure. Default: 30.
+
+
+
{!editingRoute && (
Status
@@ -623,7 +665,7 @@ export default function RoutesPage() {
Route Stops
- Drag to rearrange intermediate stops
+ Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -641,9 +683,18 @@ export default function RoutesPage() {
Select origin station above
)}
-
- 0 km
+
+ setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
+ min={1}
+ title="Check-in cutoff override (minutes) for this stop"
+ />
+
0 km
{stops.map((stop, index) => (
@@ -678,7 +729,7 @@ export default function RoutesPage() {
))}
-
-
+
+ {destinationStationId && (
+ setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
+ min={1}
+ title="Check-in cutoff override (minutes) for this stop"
+ />
+ )}
+
+
{destinationStationId && (
- {isConfirmed && (
-
- Total paid:{" "}
-
- {booking?.payment?.amountMinor != null
- ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
- : `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
-
- {booking?.payment?.method && (
-
- {" "}
- via {booking.payment.method}
-
- )}
-
- )}
{isConfirmed && (
@@ -1076,17 +1059,6 @@ function BookingDetailContent() {
- {isConfirmed && (
-
- )}
))}
@@ -1095,17 +1067,6 @@ function BookingDetailContent() {
- {isConfirmed && (
-
- )}
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
index 7dccc7bdf..b29a4b102 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx
@@ -685,6 +685,19 @@ function PassengersForm() {
const { user, isAuthenticated, updateUser } = useAuthStore();
const isInitialized = useAuthStore((s) => s.isInitialized);
const [faydaEnabled, setFaydaEnabled] = useState(true);
+ // "Skip for now" (bypasses Fayda verification) is only offered on local dev and the
+ // staging/test domain — never on an unrecognized host, which would include production.
+ // Starts false (matches SSR, where window isn't available) and is only ever flipped to
+ // true client-side after mount, so there's no server/client hydration mismatch.
+ const [allowSkipFaydaVerification, setAllowSkipFaydaVerification] = useState(false);
+ useEffect(() => {
+ const hostname = window.location.hostname;
+ setAllowSkipFaydaVerification(
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "edrpassenger.triaplc.com",
+ );
+ }, []);
const [verificationStatus, setVerificationStatus] = useState>({});
const [faydaErrors, setFaydaErrors] = useState>({});
// The passenger currently mid-verification (popup open / awaiting callback). Only one
@@ -1166,14 +1179,16 @@ function PassengersForm() {
Finish verifying Passenger {(verifyingIndex ?? 0) + 1} first
)}
- {/* toggleForm(index)}
- disabled={isVerifyingThis}
- className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
- >
- Skip for now
- */}
+ {allowSkipFaydaVerification && (
+ toggleForm(index)}
+ disabled={isVerifyingThis}
+ className="text-sm text-gray-500 dark:text-gray-400 hover:underline mt-3 block mx-auto disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Skip for now
+
+ )}
) : showManualEntryLink ? (
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index dc973d9c1..bdf370d03 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -9,6 +9,7 @@ export * from "./contracts";
export * from "./clearance-files.catalog";
export * from "./notifications";
export * from "./booking-window-ws";
+export * from "./support-chat";
export enum TradeDirection {
IMPORT = "IMPORT",
@@ -853,7 +854,6 @@ export interface BookingReferenceContainerType {
name: string;
code: string;
is_reefer: boolean;
- wagons_per_unit: number;
}
export interface BookingReferenceContainerSizeGroup {
diff --git a/packages/types/src/freight/support-chat.ts b/packages/types/src/freight/support-chat.ts
new file mode 100644
index 000000000..84b9a7262
--- /dev/null
+++ b/packages/types/src/freight/support-chat.ts
@@ -0,0 +1,99 @@
+/**
+ * Shared contracts for the freight in-app customer-support chat.
+ *
+ * There is exactly **one conversation per customer company** — any portal user
+ * of that company sees and continues the same thread, and every backoffice agent
+ * works the same shared inbox (no assignment). The thread has no lifecycle: it
+ * is created lazily by whichever side speaks first and stays open forever.
+ * Messages are text-only for the MVP.
+ *
+ * Because the thread is implied by the caller's company, the portal contract is
+ * addressed as a singleton (`/support/conversation`) and never passes an id.
+ * Agents address threads by id, since they see every company's.
+ *
+ * Mirrors the notification system's contract shape (`notifications.ts`): DTO
+ * interfaces with string dates for the wire, plus frozen WS event/namespace
+ * constants shared by the gateway (emitter) and both web apps (subscribers).
+ */
+
+/** Who authored a message — the customer side or a backoffice agent. */
+export enum SupportAuthorRole {
+ CUSTOMER = "CUSTOMER",
+ AGENT = "AGENT",
+}
+
+/** A single chat message on the wire. */
+export interface SupportMessageDto {
+ id: string;
+ conversationId: string;
+ authorUserId: string;
+ authorRole: SupportAuthorRole;
+ /** Display name of the author, resolved at send time (best-effort). */
+ authorName?: string | null;
+ body: string;
+ createdAt: string;
+}
+
+/** A company's conversation on the wire, with denormalized last-message fields. */
+export interface SupportConversationDto {
+ id: string;
+ companyId: string;
+ companyName?: string | null;
+ /** Null when an agent opened the thread — no customer created it. */
+ createdByUserId?: string | null;
+ lastMessageAt?: string | null;
+ lastMessagePreview?: string | null;
+ lastMessageAuthorRole?: SupportAuthorRole | null;
+ /**
+ * Unread count *for the caller's side* (messages authored by the other role
+ * after the caller's read cursor). Populated on list/detail responses only —
+ * WS payloads carry an unauthoritative 0, so clients must refetch, not trust it.
+ */
+ unreadCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Post a message. The portal omits the id; the thread is implied by the company. */
+export interface SendSupportMessageDto {
+ body: string;
+}
+
+/** Agent opens a thread with a company that has none yet. */
+export interface StartSupportConversationDto {
+ companyId: string;
+}
+
+/**
+ * Result of a portal send: the thread (created on the fly if this was the first
+ * message) alongside the persisted message.
+ */
+export interface SendSupportMessageResult {
+ conversation: SupportConversationDto;
+ message: SupportMessageDto;
+}
+
+/** Paginated list envelope for the agent conversations list endpoint. */
+export interface SupportConversationListResult {
+ items: SupportConversationDto[];
+ count: number;
+ /** Total unread conversations for the caller's side (badge source). */
+ unreadCount: number;
+}
+
+/** Socket.io event names pushed server → client on the `support-chat` namespace. */
+export const SUPPORT_CHAT_WS_EVENTS = {
+ /** A new message was added to a conversation the socket can see. */
+ MESSAGE_NEW: "support:message-new",
+ /** A conversation's metadata changed (last message, or a thread was opened). */
+ CONVERSATION_UPDATED: "support:conversation-updated",
+} as const;
+
+/** Socket.io namespace the support-chat gateway listens on. */
+export const SUPPORT_CHAT_WS_NAMESPACE = "support-chat";
+
+/** Payload for {@link SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW}. */
+export interface SupportMessageEvent {
+ conversation: SupportConversationDto;
+ message: SupportMessageDto;
+}