mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/chat-app
This commit is contained in:
@@ -60,6 +60,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
@@ -198,6 +199,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
YardFacilitiesSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
@@ -223,6 +225,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
@@ -260,6 +263,10 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// File upload settings — keep enabled.
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
|
||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||
// Dire Dawa). Idempotent; creates no yards.
|
||||
await this.yardFacilitiesSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
|
||||
|
||||
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||
*
|
||||
* 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}`;
|
||||
}
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS export_train_number,
|
||||
DROP COLUMN IF EXISTS import_train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<string, string[]> = {
|
||||
'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<string, string> = {
|
||||
'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<void> {
|
||||
// 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<string>();
|
||||
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons
|
||||
SET export_train_number = NULL, import_train_number = NULL
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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<Booking> {
|
||||
|
||||
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<Booking> {
|
||||
async calculateWagonCount(bookingId: string): Promise<number> {
|
||||
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 })
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
/**
|
||||
* 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<Company>,
|
||||
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
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<Company> {
|
||||
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<Company> {
|
||||
}
|
||||
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
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<string, number>();
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<CompanyProfile> = { status };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
|
||||
attributes?: Record<string, any> | 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;
|
||||
}
|
||||
|
||||
@@ -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<BookingContainer>),
|
||||
@@ -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;
|
||||
|
||||
@@ -190,12 +190,16 @@ export class PaymentService {
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||
): 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<void> {
|
||||
|
||||
@@ -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> = {}): 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<string, unknown> }) => {
|
||||
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<RateChangeRequest>) => ({ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<RateChangeRequest>,
|
||||
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<RateChangeRequest> {
|
||||
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<RateChangeRequest[]> {
|
||||
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<RateChangeRequest> {
|
||||
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<RateChangeRequest> {
|
||||
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<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const field of DIFFABLE_FIELDS) {
|
||||
const proposed = (update as Record<string, unknown>)[field];
|
||||
if (proposed === undefined) continue;
|
||||
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[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<string, unknown>): Record<string, unknown> {
|
||||
const before: Record<string, unknown> = {};
|
||||
for (const field of Object.keys(payload)) {
|
||||
before[field] = (rate as unknown as Record<string, unknown>)[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<RateChangeRequest> {
|
||||
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}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,12 +119,62 @@ export class RatesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a DRAFT rate. */
|
||||
/**
|
||||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||
*/
|
||||
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||||
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<Rate> {
|
||||
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<void> {
|
||||
await this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
|
||||
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<Partial<Rate>> {
|
||||
const id = existing.id;
|
||||
const updates: Partial<Rate> = {};
|
||||
|
||||
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
||||
@@ -179,9 +229,7 @@ export class RatesService {
|
||||
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
const updated = await this.repository.update(id, updates);
|
||||
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
||||
return updated;
|
||||
return updates;
|
||||
}
|
||||
|
||||
/** Submit a DRAFT rate for CEO approval. */
|
||||
|
||||
@@ -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<YardFacilityInfo | null> {
|
||||
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<YardFacilityInfo[]> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export class YardsService {
|
||||
label: dto.label,
|
||||
country: dto.country,
|
||||
isActive: dto.isActive ?? true,
|
||||
hasFacility: dto.hasFacility ?? false,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<CorridorBudget> {
|
||||
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<number | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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();
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?? []));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -96,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
summarizeFleetWarnings,
|
||||
@@ -318,6 +321,11 @@ export class TrainSchedulingService {
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||||
// existing specs that construct the service without it keep working.
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService?: BookingBatchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -592,7 +600,24 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async getTrainSchedulingGlobalRules() {
|
||||
return this.loadGlobalRulesRow();
|
||||
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
|
||||
}
|
||||
|
||||
/**
|
||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
||||
* planning still reads them off the row); they are no longer exposed or
|
||||
* editable through the global-rules endpoints.
|
||||
*/
|
||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||
if (!row) return row;
|
||||
const {
|
||||
maxTrainLengthMeters: _len,
|
||||
maxTrainWeightTons: _wt,
|
||||
max20ftContainerWeightTons: _cw,
|
||||
max20ftPairWeightDiffTons: _pd,
|
||||
...pub
|
||||
} = row;
|
||||
return pub;
|
||||
}
|
||||
|
||||
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||
@@ -600,15 +625,7 @@ export class TrainSchedulingService {
|
||||
if (!row) {
|
||||
throw new NotFoundException('Train scheduling global rules not configured');
|
||||
}
|
||||
if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters;
|
||||
if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons;
|
||||
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
|
||||
if (dto.max20ftContainerWeightTons != null) {
|
||||
row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons;
|
||||
}
|
||||
if (dto.max20ftPairWeightDiffTons != null) {
|
||||
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
||||
}
|
||||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||||
@@ -646,7 +663,7 @@ export class TrainSchedulingService {
|
||||
await this.restampPendingWindows();
|
||||
}
|
||||
|
||||
return saved;
|
||||
return this.toPublicGlobalRules(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5109,6 +5126,11 @@ export class TrainSchedulingService {
|
||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
|
||||
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
|
||||
// capacity, so trimming/coupling wagons moves the FULL line live.
|
||||
const wagonUsage =
|
||||
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
|
||||
|
||||
const mapWagon = (wagon: Wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
@@ -5147,6 +5169,12 @@ export class TrainSchedulingService {
|
||||
grossTons: roundTons(cargoTons + consistTareTons),
|
||||
consistLengthMeters,
|
||||
},
|
||||
scheduleCapacity: wagonUsage
|
||||
? {
|
||||
...wagonUsage,
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||
}
|
||||
: null,
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
@@ -5339,7 +5367,37 @@ export class TrainSchedulingService {
|
||||
);
|
||||
});
|
||||
|
||||
return this.getScheduleConsist(scheduleId);
|
||||
// The consist IS the schedule's booking capacity, so an edit moves the
|
||||
// FULL line: freeing slots on a FULL schedule reopens its window, taking
|
||||
// the last slot closes it. Staff may shrink below what is already
|
||||
// committed — allowed, but reported back as a warning (never silently).
|
||||
const warnings: string[] = [];
|
||||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||||
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
|
||||
if (usage) {
|
||||
const nowFull = usage.remainingSlots <= 0;
|
||||
if (usage.overAllocatedBy > 0) {
|
||||
warnings.push(
|
||||
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
|
||||
`${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
|
||||
'Couple more wagons or free bookings before departure.',
|
||||
);
|
||||
}
|
||||
if (wasFull && !nowFull) {
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
warnings.push(
|
||||
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
|
||||
'so it is no longer FULL and can take bookings again.',
|
||||
);
|
||||
} else if (!wasFull && nowFull) {
|
||||
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
|
||||
warnings.push(
|
||||
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ export class WagonsService {
|
||||
if (dto.trainId === undefined) wagon.trainId = null;
|
||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
||||
if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
|
||||
if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { generateGrnNumber } from '../../common/grn.util';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
@@ -5073,10 +5074,9 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
return generateGrnNumber(direction, referenceId, date);
|
||||
}
|
||||
|
||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||
import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers';
|
||||
import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers';
|
||||
|
||||
async function seedEdRWagons() {
|
||||
await AppDataSource.initialize();
|
||||
@@ -10,7 +12,12 @@ async function seedEdRWagons() {
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
// Fleet first (recreates every wagon with NULL runs), then the columns are
|
||||
// ensured to exist, then the run roster is applied on top. Same order the
|
||||
// migrations run in, so the script and a fresh migrate agree.
|
||||
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||
await new AddWagonTrainNumbers2270000000000().up(queryRunner);
|
||||
await new SeedWagonRunNumbers2280000000000().up(queryRunner);
|
||||
|
||||
const summary = await queryRunner.query(`
|
||||
SELECT
|
||||
@@ -20,7 +27,8 @@ async function seedEdRWagons() {
|
||||
MIN(w.wagon_number) AS first_wagon,
|
||||
MAX(w.wagon_number) AS last_wagon,
|
||||
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
||||
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard,
|
||||
COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run
|
||||
FROM freight.wagons w
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
||||
@@ -29,13 +37,33 @@ async function seedEdRWagons() {
|
||||
`);
|
||||
|
||||
const [totals] = await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS total FROM freight.wagons;
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run
|
||||
FROM freight.wagons;
|
||||
`);
|
||||
|
||||
const runs = await queryRunner.query(`
|
||||
SELECT
|
||||
export_train_number AS export_run,
|
||||
import_train_number AS import_run,
|
||||
COUNT(*)::int AS wagons
|
||||
FROM freight.wagons
|
||||
WHERE export_train_number IS NOT NULL
|
||||
GROUP BY export_train_number, import_train_number
|
||||
ORDER BY export_train_number;
|
||||
`);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
console.log('\nFleet by wagon type:');
|
||||
console.table(summary);
|
||||
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
||||
console.log('Run roster (export/import pairs):');
|
||||
console.table(runs);
|
||||
console.log(
|
||||
`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` +
|
||||
`${totals.on_a_run} on a run (expected 533).`,
|
||||
);
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
|
||||
@@ -209,7 +209,6 @@ async function ensureReferences(manager: any) {
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
|
||||
@@ -118,7 +118,6 @@ async function main() {
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs whose changes go through a separate approver. `manage` lets a staff
|
||||
* member propose a change; only `approve` lets someone put it into effect.
|
||||
* Only listed slugs get the permission — the rest are manage-only.
|
||||
*/
|
||||
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
@@ -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<void> {
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Settings2,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
@@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
{
|
||||
@@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
},
|
||||
{
|
||||
label: "Payments",
|
||||
href: "/dashboard/payments",
|
||||
@@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
// title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
label: "Operations",
|
||||
icon: <Settings />,
|
||||
children: [
|
||||
{
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// 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: <ShieldCheck />,
|
||||
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
// },
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "First Mile",
|
||||
href: "/dashboard/operations/first-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.firstMile.view,
|
||||
},
|
||||
{
|
||||
label: "Last Mile",
|
||||
href: "/dashboard/operations/last-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.lastMile.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
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: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "First Mile",
|
||||
href: "/dashboard/operations/first-mile",
|
||||
label: "Fleet Management",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.firstMile.view,
|
||||
},
|
||||
{
|
||||
label: "Last Mile",
|
||||
href: "/dashboard/operations/last-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.lastMile.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Fleet Dashboard",
|
||||
href: "/dashboard/fleet-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.fleetDashboard.view,
|
||||
},
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
label: "Fleet Dashboard",
|
||||
href: "/dashboard/fleet-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.fleetDashboard.view,
|
||||
},
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.vehicles.view,
|
||||
},
|
||||
{
|
||||
label: "Drivers",
|
||||
href: "/dashboard/drivers",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.drivers.view,
|
||||
},
|
||||
{
|
||||
label: "Track Vehicles",
|
||||
href: "/dashboard/tracking",
|
||||
icon: <MapPin />,
|
||||
permission: FREIGHT_PERMS.tracking.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Purchases",
|
||||
href: "/dashboard/fuel-purchases",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Analytics",
|
||||
href: "/dashboard/fuel-stats",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Maintenance",
|
||||
href: "/dashboard/maintenance",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Work Orders",
|
||||
href: "/dashboard/work-orders",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Compliance & Alerts",
|
||||
href: "/dashboard/compliance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Incidents",
|
||||
href: "/dashboard/incidents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Financial Reports",
|
||||
href: "/dashboard/financial-reports",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.fleetReports.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.vehicles.view,
|
||||
},
|
||||
{
|
||||
label: "Drivers",
|
||||
href: "/dashboard/drivers",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.drivers.view,
|
||||
},
|
||||
{
|
||||
label: "Track Vehicles",
|
||||
href: "/dashboard/tracking",
|
||||
icon: <MapPin />,
|
||||
permission: FREIGHT_PERMS.tracking.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Purchases",
|
||||
href: "/dashboard/fuel-purchases",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Analytics",
|
||||
href: "/dashboard/fuel-stats",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Maintenance",
|
||||
href: "/dashboard/maintenance",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Work Orders",
|
||||
href: "/dashboard/work-orders",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Compliance & Alerts",
|
||||
href: "/dashboard/compliance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Incidents",
|
||||
href: "/dashboard/incidents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Financial Reports",
|
||||
href: "/dashboard/financial-reports",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.fleetReports.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
@@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
label: "Warehouse Management",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
children: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
title: "Freight configuration",
|
||||
mutedTitle: true,
|
||||
items: [
|
||||
{
|
||||
label: "File settings",
|
||||
@@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Freight configuration",
|
||||
mutedTitle: true,
|
||||
items: [
|
||||
{
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
@@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <SlidersHorizontal />,
|
||||
children: getCategorySidebarChildren("rules"),
|
||||
},
|
||||
|
||||
{
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -599,7 +599,10 @@ const findActiveSidebarLabel = (
|
||||
): string | undefined => {
|
||||
const path = pathname.toLowerCase();
|
||||
const candidates = flattenSidebarItems(sections)
|
||||
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
|
||||
.map(({ href, label }) => ({
|
||||
label,
|
||||
href: href.split("?")[0].toLowerCase(),
|
||||
}))
|
||||
.sort((a, b) => b.href.length - a.href.length);
|
||||
|
||||
return candidates.find(
|
||||
@@ -674,10 +677,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route
|
||||
path="um/set-password"
|
||||
element={<SetPassword />}
|
||||
/>
|
||||
<Route path="um/set-password" element={<SetPassword />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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({
|
||||
</Modal>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<Tooltip label="Available once the customer submits their onboarding application">
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Awaiting submission
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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<string, boolean> = {};
|
||||
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<string, unknown> = { ...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 (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
value={field.derivedValue(values)}
|
||||
readOnly
|
||||
variant="filled"
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "radio") {
|
||||
return (
|
||||
<Radio.Group
|
||||
@@ -319,6 +350,8 @@ const FleetFormDialog = ({
|
||||
<Select
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
data={field.options ?? []}
|
||||
value={
|
||||
value == null || value === ""
|
||||
@@ -332,6 +365,7 @@ const FleetFormDialog = ({
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
clearable={field.clearable}
|
||||
disabled={selectOptionsLoading || isDisabled}
|
||||
rightSection={
|
||||
selectOptionsLoading ? (
|
||||
|
||||
@@ -92,7 +92,9 @@ const FreightSidebar = ({
|
||||
walk(item.children, key);
|
||||
});
|
||||
};
|
||||
sections.forEach((section) => 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={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -142,7 +145,7 @@ const FreightSidebar = ({
|
||||
size={16}
|
||||
className="text-edr-muted transition-transform duration-200"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -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) => (
|
||||
<Box key={section.title}>
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
{section.title && (
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
{section.items.map((item, i) =>
|
||||
renderItem(item, itemKey(section.title, item, i)),
|
||||
renderItem(item, itemKey(section.title ?? "" + i, item, i)),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -257,7 +263,7 @@ const FreightSidebar = ({
|
||||
px="sm"
|
||||
pb="md"
|
||||
>
|
||||
<Stack gap="lg">{renderedSections}</Stack>
|
||||
<Stack gap="md">{renderedSections}</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>("ALL");
|
||||
const [runOnly, setRunOnly] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
// 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<string, string>();
|
||||
@@ -112,6 +134,15 @@ export default function AvailableWagonsPanel({
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{runLabel ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
label={`Only wagons on this train's run (${runLabel}) — ${runMatchCount} here`}
|
||||
checked={runOnly}
|
||||
onChange={(e) => setRunOnly(e.currentTarget.checked)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{wagons.length ? (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
@@ -151,9 +182,24 @@ export default function AvailableWagonsPanel({
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
{wagon.exportTrainNumber ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
|
||||
}
|
||||
>
|
||||
{wagon.exportTrainNumber}
|
||||
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { EXPORT_TRAIN_OPTIONS, importRunFor } from "@/constants/trainRuns";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -26,10 +27,6 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
// Run-number parity carries the trade direction: odd = export, even = import.
|
||||
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
|
||||
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
|
||||
|
||||
/**
|
||||
* Step one of the Train Builder: pick the yard it is being assembled in and
|
||||
* couple at least two locomotives from that yard. The train code is assigned by
|
||||
@@ -59,6 +56,12 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
setLocomotiveIds([]);
|
||||
}, [yardId]);
|
||||
|
||||
// The import run is fixed by the export run, so it tracks it rather than
|
||||
// being entered by hand (and clears back to empty when the export is cleared).
|
||||
useEffect(() => {
|
||||
setImportTrainNumber(importRunFor(exportTrainNumber));
|
||||
}, [exportTrainNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setExportTrainNumber("");
|
||||
@@ -78,9 +81,11 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
|
||||
// Both numbers come from the fixed run pairs, so parity cannot be wrong —
|
||||
// only "nothing picked" is reachable here.
|
||||
if (!exportTrainNumber || !importTrainNumber) {
|
||||
toast({
|
||||
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
|
||||
title: "Pick an export train number (e.g. 8001) — the import run follows it",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -133,31 +138,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
maxLength={100}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Export train number"
|
||||
description="Odd — Ethiopia → Djibouti runs"
|
||||
placeholder="e.g. 8001"
|
||||
value={exportTrainNumber}
|
||||
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
error={
|
||||
exportTrainNumber && !isOddNumber(exportTrainNumber)
|
||||
? "Must be numeric and odd"
|
||||
: undefined
|
||||
}
|
||||
data={EXPORT_TRAIN_OPTIONS}
|
||||
value={exportTrainNumber || null}
|
||||
onChange={(value) => setExportTrainNumber(value ?? "")}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{/* Fixed by the export run — derived, never typed. */}
|
||||
<TextInput
|
||||
label="Import train number"
|
||||
description="Even — Djibouti → Ethiopia runs"
|
||||
placeholder="e.g. 8002"
|
||||
value={importTrainNumber}
|
||||
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
error={
|
||||
importTrainNumber && !isEvenNumber(importTrainNumber)
|
||||
? "Must be numeric and even"
|
||||
: undefined
|
||||
}
|
||||
readOnly
|
||||
variant="filled"
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
|
||||
@@ -65,7 +65,10 @@ export default function AdjustConsistModal({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
||||
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
||||
// (weight/length only bind while assembling the consist), so trims/adds
|
||||
// move the FULL line in real time.
|
||||
const projection = useMemo(() => {
|
||||
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}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{projection?.slots ? (
|
||||
<Grid.Col span={12}>
|
||||
<LimitGauge
|
||||
label="Booking slots — the consist is the schedule's capacity"
|
||||
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
|
||||
projection.slots.free > 0
|
||||
? ` — ${projection.slots.free} free`
|
||||
: projection.slots.free === 0
|
||||
? " — none free (FULL)"
|
||||
: ""
|
||||
}`}
|
||||
pct={projection.slots.pct}
|
||||
over={projection.slots.overAllocated}
|
||||
/>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
</Grid>
|
||||
|
||||
{projection?.slots?.isFullNow && !hasChanges ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
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.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges && projection?.slots?.overAllocated ? (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||||
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.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges &&
|
||||
projection?.slots &&
|
||||
!projection.slots.overAllocated &&
|
||||
projection.slots.willBeFull &&
|
||||
!projection.slots.isFullNow ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This change takes the last free wagon slot — the schedule becomes
|
||||
FULL and stops accepting bookings.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges && projection?.slots?.willReopen ? (
|
||||
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||||
This schedule is currently FULL — applying frees{" "}
|
||||
{projection.slots.free} wagon slot(s) and reopens its booking
|
||||
window.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
35
apps/edr-freight-web/backoffice/src/constants/trainRuns.ts
Normal file
35
apps/edr-freight-web/backoffice/src/constants/trainRuns.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
"8001": "8002",
|
||||
"8101": "8102",
|
||||
"8201": "8202",
|
||||
"8301": "8302",
|
||||
"8401": "8402",
|
||||
"8501": "8502",
|
||||
"8601": "8602",
|
||||
"8701": "8702",
|
||||
"8801": "8802",
|
||||
"8901": "8902",
|
||||
"9001": "9002",
|
||||
};
|
||||
|
||||
/** Selectable export runs, in run order. */
|
||||
export const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({
|
||||
label: run,
|
||||
value: run,
|
||||
}));
|
||||
|
||||
/** The import run implied by an export run; empty string when unset/unknown. */
|
||||
export const importRunFor = (exportRun: unknown): string =>
|
||||
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
|
||||
@@ -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 {
|
||||
@@ -318,6 +319,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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,7 +61,6 @@ interface RefContainerType {
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer?: boolean;
|
||||
wagons_per_unit?: number;
|
||||
}
|
||||
interface RefContainerGroup {
|
||||
size: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -60,6 +62,7 @@ import type {
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
} from "@/types/customer";
|
||||
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -166,6 +169,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<CompanyProfile>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -273,11 +283,12 @@ export default function CustomerDetailPage() {
|
||||
<ProfileApprovalActions
|
||||
profileId={row.original.id}
|
||||
status={row.original.status}
|
||||
locked={!canReview}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[view],
|
||||
[view, canReview],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -602,7 +613,13 @@ export default function CustomerDetailPage() {
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
{stillOnboarding ? (
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding in progress
|
||||
</Badge>
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -631,6 +648,21 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
{stillOnboarding && (
|
||||
<Alert
|
||||
color="gray"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Hourglass size={18} />}
|
||||
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.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -642,10 +674,16 @@ export default function CustomerDetailPage() {
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending approval",
|
||||
value: company.companyProfiles.filter(
|
||||
(p) => 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",
|
||||
},
|
||||
|
||||
@@ -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<CustomerView>("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 (
|
||||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
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() {
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
|
||||
import { EXPORT_TRAIN_OPTIONS, importRunFor } 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 +41,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, unknown>) => 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.
|
||||
@@ -118,6 +133,7 @@ const WAGON_STATUS_OPTIONS = [
|
||||
|
||||
|
||||
|
||||
|
||||
export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{
|
||||
slug: "locomotives",
|
||||
@@ -261,16 +277,49 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
],
|
||||
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 export run. The import run is fixed by
|
||||
// that choice, so it is derived rather than typed.
|
||||
{
|
||||
name: "exportTrainNumber",
|
||||
label: "Export train number",
|
||||
type: "select",
|
||||
description: "Odd — Ethiopia → Djibouti runs",
|
||||
placeholder: "e.g. 8001",
|
||||
options: EXPORT_TRAIN_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
{
|
||||
name: "importTrainNumber",
|
||||
label: "Import train number",
|
||||
type: "text",
|
||||
description: "Even — Djibouti → Ethiopia runs",
|
||||
placeholder: "e.g. 8002",
|
||||
derivedValue: (values) => importRunFor(values.exportTrainNumber),
|
||||
// Follows the export run to NULL when that is cleared.
|
||||
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 +327,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "notes", label: "Notes", type: "textarea" },
|
||||
],
|
||||
emptyValues: {
|
||||
exportTrainNumber: "",
|
||||
importTrainNumber: "",
|
||||
wagonNumber: "",
|
||||
wagonTypeId: "",
|
||||
currentYardId: "",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" mb="md">
|
||||
<Group gap={8} mb={4}>
|
||||
<Clock size={16} />
|
||||
<Text fw={700}>Pending rate changes</Text>
|
||||
<Badge variant="light" color="yellow">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
Each rate below still charges its current value. Nothing changes until approved.
|
||||
</Text>
|
||||
|
||||
<Stack gap={8}>
|
||||
{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 (
|
||||
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
update
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{rateSummary(r)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{summaryLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue("rateValue", r.previousValues.rateValue)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={700} c="edr-green">
|
||||
{fmtValue("rateValue", r.payload.rateValue)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide details" : "See all changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{canDecide ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={14} />}
|
||||
loading={busy && reject.isPending}
|
||||
disabled={busy && approve.isPending}
|
||||
onClick={() =>
|
||||
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={busy && approve.isPending}
|
||||
disabled={busy && reject.isPending}
|
||||
onClick={() =>
|
||||
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Approve & apply
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label="You need the rates approve permission to decide this">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Awaiting approver
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Collapse in={isOpen}>
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{fields.map((field) => (
|
||||
<Group key={field} gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field])}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field])}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{canDecide ? (
|
||||
<Textarea
|
||||
mt={4}
|
||||
size="xs"
|
||||
autosize
|
||||
minRows={2}
|
||||
label="Decision note (optional)"
|
||||
placeholder="Shown to the requester with your decision"
|
||||
value={notes[r.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateApprovalsSection;
|
||||
@@ -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";
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
usePriorityRuleWorkflow,
|
||||
useRateChangeWorkflow,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -51,6 +57,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,
|
||||
@@ -145,6 +152,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<string | null>(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<string, RateChangeRequest>();
|
||||
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.
|
||||
@@ -306,7 +330,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 (
|
||||
<Stack gap={0}>
|
||||
{cell}
|
||||
<Tooltip label="Awaiting approval — this rate still charges its current value">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Clock size={11} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{Number(change.payload.rateValue).toLocaleString()} pending
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
base.push({
|
||||
@@ -357,6 +401,8 @@ const RuleEngineResourcePage = () => {
|
||||
}, [
|
||||
canManage,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
submit,
|
||||
handleApproveRate,
|
||||
handleMoveOrder,
|
||||
@@ -400,6 +446,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 +544,31 @@ const RuleEngineResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isRates ? (
|
||||
<RateApprovalsSection
|
||||
requests={rateChangeWorkflow.pending.data ?? []}
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={rateError != null}
|
||||
onClose={() => setRateError(null)}
|
||||
title="Cannot save rate change"
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="red">
|
||||
{rateError}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={() => setRateError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={priorityError != null}
|
||||
onClose={() => setPriorityError(null)}
|
||||
|
||||
@@ -384,7 +384,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 +477,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 +495,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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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<PriorityRuleType, number> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,8 @@ export default function TrainBuilderDetailPage() {
|
||||
<AvailableWagonsPanel
|
||||
yardId={yard?.id ?? ""}
|
||||
yardLabel={yard?.label}
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
onAssign={(wagonIds) =>
|
||||
void withToast(
|
||||
|
||||
@@ -50,11 +50,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
// refilled) must not silently save as 0. Collect the numeric payload and
|
||||
// reject if any value is blank or NaN.
|
||||
const fields: (keyof TrainSchedulingGlobalRules)[] = [
|
||||
"maxTrainLengthMeters",
|
||||
"maxTrainWeightTons",
|
||||
"maxWagonsPerTrain",
|
||||
"max20ftContainerWeightTons",
|
||||
"max20ftPairWeightDiffTons",
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
@@ -98,32 +94,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
|
||||
<Card maw={720}>
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="Max train length (m)"
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max train weight (T)"
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
@@ -136,38 +106,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft container weight (T)"
|
||||
description="Each individual 20ft container gross weight limit"
|
||||
value={form.max20ftContainerWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft pair weight difference (T)"
|
||||
description="When two 20ft containers share a wagon, |weight1 − weight2| must not exceed this"
|
||||
value={form.max20ftPairWeightDiffTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -48,6 +48,30 @@ export interface SubmitPriorityRuleChangePayload {
|
||||
update?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Approval workflow for edits to LIVE rates — a DRAFT rate still edits directly. */
|
||||
const RATE_CHANGES_BASE = "/rate-change-requests";
|
||||
|
||||
export interface RateChangeRequest {
|
||||
id: string;
|
||||
rateId: string;
|
||||
rate?: RuleEngineRecord | null;
|
||||
/** Changed fields only. */
|
||||
payload: Record<string, unknown>;
|
||||
/** What those same fields were when the change was filed. */
|
||||
previousValues: Record<string, unknown>;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
requestedByUserId: string | null;
|
||||
decidedByUserId: string | null;
|
||||
decidedAt: string | null;
|
||||
decisionNote: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SubmitRateChangePayload {
|
||||
rateId: string;
|
||||
update: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
@@ -306,6 +330,44 @@ export const ruleEngineService = {
|
||||
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||
},
|
||||
|
||||
/** Propose a change to a LIVE rate — it stays at its current value until approved. */
|
||||
submitRateChange: async (
|
||||
payload: SubmitRateChangePayload,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(RATE_CHANGES_BASE, payload);
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
listRateChanges: async (
|
||||
status?: RateChangeRequest["status"],
|
||||
): Promise<RateChangeRequest[]> => {
|
||||
const response = await client.get(RATE_CHANGES_BASE, {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
const body = unwrap(response.data) as unknown;
|
||||
return Array.isArray(body) ? (body as RateChangeRequest[]) : [];
|
||||
},
|
||||
|
||||
approveRateChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/approve`, {
|
||||
decisionNote,
|
||||
});
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
rejectRateChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/reject`, {
|
||||
decisionNote,
|
||||
});
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
|
||||
@@ -235,6 +235,18 @@ export interface ScheduleConsist {
|
||||
occurredAt: string;
|
||||
}>;
|
||||
editable: boolean;
|
||||
/**
|
||||
* Wagon-slot picture of the schedule: the consist IS the booking capacity
|
||||
* (weight/length only bind while building the consist), so the dialog can
|
||||
* project FULL / reopen / over-allocation live. Null on legacy schedules.
|
||||
*/
|
||||
scheduleCapacity: {
|
||||
maxWagons: number;
|
||||
allocatedWagons: number;
|
||||
remainingSlots: number;
|
||||
overAllocatedBy: number;
|
||||
bookingWindowStatus: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdjustConsistPayload {
|
||||
@@ -242,6 +254,9 @@ export interface AdjustConsistPayload {
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -275,7 +290,7 @@ export const trainBuilderService = {
|
||||
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||
/** Permanently trim/add wagons on the schedule's built train. */
|
||||
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||
apiClient.post<ScheduleConsist>(
|
||||
apiClient.post<AdjustConsistResult>(
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface Wagon {
|
||||
status: Freight.WagonStatus;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||
exportTrainNumber?: string | null;
|
||||
/** Even IMPORT run (Djibouti → Ethiopia); always the export run + 1. */
|
||||
importTrainNumber?: string | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -146,10 +146,38 @@ export interface Company {
|
||||
website?: string | null;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
companyProfiles: CompanyProfile[];
|
||||
/**
|
||||
* Whether the customer submitted their onboarding application. A company row
|
||||
* is created on the wizard's first click, so a `pending` company with this
|
||||
* false is a half-filled draft — not reviewable. Staff-created companies are
|
||||
* always true. Undefined on endpoints that don't load external profiles.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the customer has submitted their onboarding application. Mirrors the
|
||||
* API's review gate (`setCompanyProfileStatus`): until this is true, a role
|
||||
* awaiting a decision cannot be approved or rejected. Companies loaded without
|
||||
* external profiles (`undefined`) are treated as submitted — absence of the
|
||||
* flag must not lock staff out.
|
||||
*/
|
||||
export function hasSubmittedOnboarding(company: Company): boolean {
|
||||
return company.onboardingCompleted !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pristine draft: still `pending` and never submitted, so its name/TIN are
|
||||
* placeholders and there is nothing to review. Drives presentation only — the
|
||||
* approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted)
|
||||
* case of a company activated before that gate existed.
|
||||
*/
|
||||
export function isOnboardingDraft(company: Company): boolean {
|
||||
return company.status === "pending" && !hasSubmittedOnboarding(company);
|
||||
}
|
||||
|
||||
/** Query parameters for the company list. */
|
||||
export interface CompanyListFilter {
|
||||
page: number;
|
||||
@@ -158,6 +186,8 @@ export interface CompanyListFilter {
|
||||
type?: CompanyType;
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the bookings service shape). */
|
||||
@@ -170,7 +200,10 @@ export interface PaginatedCompanies {
|
||||
export interface CompanyStats {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export interface ContainerUnitRow {
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
@@ -113,11 +112,7 @@ export interface DeferredBookingRow {
|
||||
|
||||
export interface TrainSchedulingGlobalRules {
|
||||
id: string;
|
||||
maxTrainLengthMeters: number;
|
||||
maxTrainWeightTons: number;
|
||||
maxWagonsPerTrain: number;
|
||||
max20ftContainerWeightTons: number;
|
||||
max20ftPairWeightDiffTons: number;
|
||||
importWindowLeadDays: number;
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
|
||||
@@ -854,7 +854,6 @@ export interface BookingReferenceContainerType {
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer: boolean;
|
||||
wagons_per_unit: number;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerSizeGroup {
|
||||
|
||||
Reference in New Issue
Block a user