mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
||||||
|
"seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
|
|||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
|
||||||
|
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
@@ -59,6 +60,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
|||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
|
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
|
||||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||||
@@ -159,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
SupportChatModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
@@ -196,6 +199,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
FreightPositionsSeeder,
|
FreightPositionsSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
|
YardFacilitiesSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||||
// DemoUsersSeeder,
|
// DemoUsersSeeder,
|
||||||
@@ -221,6 +225,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
|
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
@@ -258,6 +263,10 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// File upload settings — keep enabled.
|
// File upload settings — keep enabled.
|
||||||
await this.fileUploadSettingsSeeder.run();
|
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
|
// Dropdown settings are not seeded on boot; run them with
|
||||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
// `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 { FreightPermissionGuard } from './freight-permission.guard';
|
||||||
import {
|
import {
|
||||||
FREIGHT_PERMS,
|
FREIGHT_PERMS,
|
||||||
|
type RuleEngineApprovableSlug,
|
||||||
type RuleEngineResourceSlug,
|
type RuleEngineResourceSlug,
|
||||||
} from '../seed/freight-permissions.registry';
|
} from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
@@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
|||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
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)])),
|
||||||
|
);
|
||||||
|
|||||||
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
|
||||||
|
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
|
||||||
|
*
|
||||||
|
* A booking reaches a train through WAGON ALLOCATION
|
||||||
|
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
|
||||||
|
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
|
||||||
|
* written by the demo seeders, so both sources are unioned: real allocations work
|
||||||
|
* and the seeded scenarios keep working.
|
||||||
|
*
|
||||||
|
* Shared so the warehouse loading queue and the train dispatch guard agree on
|
||||||
|
* exactly which bookings are on a train — if they drift, a train can be
|
||||||
|
* dispatched leaving cargo the warehouse still thinks it should load.
|
||||||
|
*/
|
||||||
|
export const SCHEDULE_BOOKINGS_CTE = `
|
||||||
|
sched_bookings AS (
|
||||||
|
SELECT ts.id AS schedule_id, wba.booking_id
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_set_wagons tsw
|
||||||
|
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
|
||||||
|
JOIN freight.wagon_booking_allocations wba
|
||||||
|
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT tsb.train_schedule_id, tsb.booking_id
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.deleted_at IS NULL
|
||||||
|
)`;
|
||||||
@@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
|
|||||||
// train_set_wagons null their link, wagon_movements cascade.
|
// train_set_wagons null their link, wagon_movements cascade.
|
||||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
|
||||||
// Wagon.wagonNumber declares `unique: true`, but some environments never got
|
// Deliberately does NOT create a unique index on wagon_number. It once did,
|
||||||
// the constraint. Repair it here — the table is empty at this point, so the
|
// to satisfy an ON CONFLICT clause that no longer exists (the DELETE above
|
||||||
// index build cannot fail on pre-existing duplicates.
|
// makes collisions impossible). Recreating the plain index here would undo
|
||||||
await queryRunner.query(`
|
// WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
|
// unique index so soft-deleted wagons stop reserving their number — this
|
||||||
ON freight.wagons (wagon_number);
|
// seeder is run directly by scripts/seed-edr-wagons.ts, which would
|
||||||
`);
|
// otherwise resurrect the plain index on an already-migrated database.
|
||||||
|
|
||||||
for (const row of FLEET) {
|
for (const row of FLEET) {
|
||||||
if (row.end - row.start + 1 !== row.count) {
|
if (row.end - row.start + 1 !== row.count) {
|
||||||
|
|||||||
@@ -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,68 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand the whole wagon fleet in Doraleh.
|
||||||
|
*
|
||||||
|
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
|
||||||
|
* wagon with a NULL yard — so this must stay later in timestamp order.
|
||||||
|
*
|
||||||
|
* A wagon with no yard cannot be coupled to a train (the train builder only
|
||||||
|
* offers AVAILABLE wagons standing in the train's own yard), which left the
|
||||||
|
* seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs
|
||||||
|
* originate from.
|
||||||
|
*
|
||||||
|
* The yard is created when absent: environments disagree about which yards
|
||||||
|
* exist, so this cannot assume one is there.
|
||||||
|
*/
|
||||||
|
const YARD_CODE = 'DORALEH';
|
||||||
|
|
||||||
|
export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface {
|
||||||
|
name = 'SeedWagonYardDoraleh2290000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Ensure the yard exists and is usable. Deliberately does NOT overwrite an
|
||||||
|
// existing label/country — a deployment that already calls this yard
|
||||||
|
// something else keeps its own naming.
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
INSERT INTO freight.yards (code, label, country, is_active, display_order)
|
||||||
|
VALUES ($1, 'Doraleh', 'Djibouti', true, 12)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
is_active = true,
|
||||||
|
deleted_at = NULL,
|
||||||
|
updated_at = now();
|
||||||
|
`,
|
||||||
|
[YARD_CODE],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [yard] = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||||
|
[YARD_CODE],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!yard?.id) {
|
||||||
|
throw new Error(`yard_missing:${YARD_CODE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole fleet — a wagon already coupled to a built train follows the train,
|
||||||
|
// so leave those where they stand.
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
UPDATE freight.wagons
|
||||||
|
SET current_yard_id = $1::uuid,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE train_id IS NULL;
|
||||||
|
`,
|
||||||
|
[yard.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Back to the state SeedEdrWagonFleetErNumbering leaves them in.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.wagons
|
||||||
|
SET current_yard_id = NULL
|
||||||
|
WHERE train_id IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-support chat. A `support_conversations` row is the single ongoing
|
||||||
|
* thread with a company; `support_messages` are its text messages. There is no
|
||||||
|
* lifecycle column — a thread is opened by whichever side speaks first and
|
||||||
|
* stays open. Enum-like columns are varchar (no PG enum churn).
|
||||||
|
*
|
||||||
|
* The unique index on `company_id` is load-bearing, not just an optimization:
|
||||||
|
* the get-or-create path depends on it to settle concurrent first-messages.
|
||||||
|
* It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
|
||||||
|
* a fresh one.
|
||||||
|
*/
|
||||||
|
export class CreateSupportChat2310000000000 implements MigrationInterface {
|
||||||
|
name = "CreateSupportChat2310000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.support_conversations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
company_id uuid NOT NULL,
|
||||||
|
company_name varchar(200),
|
||||||
|
created_by_user_id uuid,
|
||||||
|
last_message_at timestamptz,
|
||||||
|
last_message_preview varchar(280),
|
||||||
|
last_message_author_role varchar(12),
|
||||||
|
customer_last_read_at timestamptz,
|
||||||
|
agent_last_read_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
|
||||||
|
ON freight.support_conversations (company_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
|
||||||
|
ON freight.support_conversations (last_message_at)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.support_messages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id uuid NOT NULL,
|
||||||
|
author_user_id uuid NOT NULL,
|
||||||
|
author_role varchar(12) NOT NULL,
|
||||||
|
author_name varchar(200),
|
||||||
|
body text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
|
||||||
|
ON freight.support_messages (conversation_id, created_at)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.support_conversations`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope base rail freight to a route (origin yard → destination yard).
|
||||||
|
*
|
||||||
|
* Until now a base-freight rate was keyed by direction + container/bulk scope
|
||||||
|
* only, so "container import" cost the same whether the box was railed to Dire
|
||||||
|
* Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which
|
||||||
|
* is what the business actually sells: `container import, Djibouti → Dire Dawa,
|
||||||
|
* 500 USD`.
|
||||||
|
*
|
||||||
|
* Existing base-freight rates predate the yard pair and cannot be backfilled —
|
||||||
|
* there is no way to know which route each was meant for. They are retired
|
||||||
|
* (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot
|
||||||
|
* and rate_change_requests hold FKs to them (RESTRICT) and those rows are price
|
||||||
|
* history. Retiring drops them out of pricing and the admin UI just the same;
|
||||||
|
* the yard-scoped replacements must be re-entered.
|
||||||
|
*
|
||||||
|
* Surcharges, first-mile and last-mile rates are untouched: they are not
|
||||||
|
* route-scoped and keep NULL yards.
|
||||||
|
*/
|
||||||
|
export class AddRateYardScope2320000000000 implements MigrationInterface {
|
||||||
|
name = 'AddRateYardScope2320000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── 1. Yard columns + FKs ──────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "FK_rates_origin_yard_id"
|
||||||
|
FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "FK_rates_destination_yard_id"
|
||||||
|
FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 2. Retire route-less base freight ──────────────────────────────────
|
||||||
|
// Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE
|
||||||
|
// RESTRICT and those snapshots are what past bookings were charged.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND "trigger" = 'ALWAYS'
|
||||||
|
AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY');
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── 3. Route is part of a rate's identity ──────────────────────────────
|
||||||
|
// Two rates may now share rateType + scope + unit as long as they price
|
||||||
|
// different legs, so the yard pair joins the uniqueness tuple.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||||
|
ON freight.rates (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
rate_unit
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── 4. Base freight must carry a route; nothing else may ───────────────
|
||||||
|
// Retired rows are exempt — they are the route-less rates step 2 just
|
||||||
|
// superseded, and they must stay readable for snapshot history.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// The retired rates are not un-superseded: which route each belonged to was
|
||||||
|
// never recorded, so reviving them would restore rates that price the wrong
|
||||||
|
// legs. Down only reverses the schema.
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||||
|
ON freight.rates (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
rate_unit
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
DROP COLUMN IF EXISTS destination_yard_id,
|
||||||
|
DROP COLUMN IF EXISTS origin_yard_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,9 @@ import {
|
|||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
|
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import {
|
import { wagonRemainder } from './consolidation.service';
|
||||||
containersPerWagon,
|
|
||||||
wagonRemainder,
|
|
||||||
} from './consolidation.service';
|
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
@@ -308,7 +306,7 @@ export class BookingPricingService {
|
|||||||
totalVgmTons: qty * vgm,
|
totalVgmTons: qty * vgm,
|
||||||
isReefer: ct.isReefer,
|
isReefer: ct.isReefer,
|
||||||
},
|
},
|
||||||
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
|
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -477,7 +475,14 @@ export class BookingPricingService {
|
|||||||
const wagonCount = await this.resolveWagonCount(booking);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
const rate = this.pickRate(
|
||||||
|
liveRates,
|
||||||
|
rateType,
|
||||||
|
container.containerTypeId,
|
||||||
|
'USD',
|
||||||
|
booking.originYardId,
|
||||||
|
booking.destinationYardId,
|
||||||
|
);
|
||||||
if (!rate) continue;
|
if (!rate) continue;
|
||||||
|
|
||||||
usedRatesMap.set(rate.id, rate);
|
usedRatesMap.set(rate.id, rate);
|
||||||
@@ -517,8 +522,15 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
|
// Bulk (and any booking with no container lines) still has to price off a
|
||||||
|
// rate configured for this leg — never one belonging to another route.
|
||||||
const fallback = liveRates.find(
|
const fallback = liveRates.find(
|
||||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
(r) =>
|
||||||
|
r.rateType === rateType &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
r.status === 'LIVE' &&
|
||||||
|
r.originYardId === booking.originYardId &&
|
||||||
|
r.destinationYardId === booking.destinationYardId,
|
||||||
);
|
);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
usedRatesMap.set(fallback.id, fallback);
|
usedRatesMap.set(fallback.id, fallback);
|
||||||
@@ -713,20 +725,32 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base freight is quoted per leg, so a rate only applies to a booking running
|
||||||
|
* the exact origin → destination it was configured for. There is deliberately
|
||||||
|
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
|
||||||
|
* because nobody configured Mojo yet is worse than surfacing no line at all.
|
||||||
|
* Within the leg, a rate scoped to the container type wins over one that
|
||||||
|
* covers every type.
|
||||||
|
*/
|
||||||
private pickRate(
|
private pickRate(
|
||||||
rates: Rate[],
|
rates: Rate[],
|
||||||
rateType: string,
|
rateType: string,
|
||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
currency: string,
|
currency: string,
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
): Rate | undefined {
|
): Rate | undefined {
|
||||||
|
const onLeg = rates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === rateType &&
|
||||||
|
r.currency === currency &&
|
||||||
|
r.originYardId === originYardId &&
|
||||||
|
r.destinationYardId === destinationYardId,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
rates.find(
|
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
|
||||||
(r) =>
|
onLeg.find((r) => !r.containerTypeId)
|
||||||
r.rateType === rateType &&
|
|
||||||
r.currency === currency &&
|
|
||||||
r.containerTypeId === containerTypeId,
|
|
||||||
) ??
|
|
||||||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ export function groupContainersBySize(
|
|||||||
name: ct.label?.trim() ? ct.label : ct.code,
|
name: ct.label?.trim() ? ct.label : ct.code,
|
||||||
code: ct.code,
|
code: ct.code,
|
||||||
is_reefer: ct.isReefer ?? false,
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from '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 { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.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) {
|
for (const item of containers) {
|
||||||
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
|
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 totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||||
// A per-line breakdown can never exceed the line's own quantity.
|
// 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> {
|
async calculateWagonCount(bookingId: string): Promise<number> {
|
||||||
const result = await this.dataSource
|
const result = await this.dataSource
|
||||||
.createQueryBuilder()
|
.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')
|
.from(BookingContainer, 'bc')
|
||||||
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
|
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
|
||||||
.where('bc.booking_id = :bookingId', { bookingId })
|
.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 { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import {
|
import {
|
||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
@@ -438,7 +439,7 @@ export class BookingsService {
|
|||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
isReefer: ct.isReefer,
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
|
|||||||
messages: string[];
|
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 {
|
export function wagonRemainder(quantity: number, perWagon: number): number {
|
||||||
const r = quantity % perWagon;
|
const r = quantity % perWagon;
|
||||||
return r;
|
return r;
|
||||||
@@ -73,7 +67,7 @@ export class ConsolidationService {
|
|||||||
const slots: ConsolidationSlot[] = [];
|
const slots: ConsolidationSlot[] = [];
|
||||||
for (const [containerTypeId, quantity] of quantityByType) {
|
for (const [containerTypeId, quantity] of quantityByType) {
|
||||||
const ct = await this.containerTypesService.findById(containerTypeId);
|
const ct = await this.containerTypesService.findById(containerTypeId);
|
||||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
const perWagon = containersPerWagonForSize(ct.sizeFt);
|
||||||
const remainder = wagonRemainder(quantity, perWagon);
|
const remainder = wagonRemainder(quantity, perWagon);
|
||||||
if (remainder === 0) continue;
|
if (remainder === 0) continue;
|
||||||
slots.push({
|
slots.push({
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
is_reefer!: boolean;
|
is_reefer!: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
|
|
||||||
wagons_per_unit!: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingReferenceContainerSizeGroupDto {
|
export class BookingReferenceContainerSizeGroupDto {
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompaniesRepository extends BaseRepository<Company> {
|
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(
|
constructor(
|
||||||
@InjectRepository(Company)
|
@InjectRepository(Company)
|
||||||
repo: Repository<Company>,
|
repo: Repository<Company>,
|
||||||
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
async findPaginated(
|
async findPaginated(
|
||||||
query: ListCompaniesQueryDto,
|
query: ListCompaniesQueryDto,
|
||||||
): Promise<{ items: Company[]; total: number }> {
|
): 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
|
const qb = this.repository
|
||||||
.createQueryBuilder('company')
|
.createQueryBuilder('company')
|
||||||
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
|
.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');
|
.where('company.deleted_at IS NULL');
|
||||||
|
|
||||||
if (type) {
|
if (type) {
|
||||||
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
qb.andWhere('company.status = :status', { status });
|
qb.andWhere('company.status = :status', { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onboardingCompleted !== undefined) {
|
||||||
|
qb.andWhere(
|
||||||
|
onboardingCompleted
|
||||||
|
? `NOT ${CompaniesRepository.DRAFT_SQL}`
|
||||||
|
: CompaniesRepository.DRAFT_SQL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const term = `%${search.trim()}%`;
|
const term = `%${search.trim()}%`;
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||||
const rows: { status: string; count: string }[] = await this.repository
|
// Drafts are counted separately rather than under `pending`: they carry
|
||||||
.createQueryBuilder('company')
|
// status=pending from creation, which would otherwise inflate the review
|
||||||
.select('company.status', 'status')
|
// queue's KPI with customers who haven't submitted anything yet.
|
||||||
.addSelect('COUNT(*)', 'count')
|
const rows: { status: string; is_draft: boolean; count: string }[] =
|
||||||
.where('company.deleted_at IS NULL')
|
await this.repository
|
||||||
.groupBy('company.status')
|
.createQueryBuilder('company')
|
||||||
.getRawMany();
|
.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 map = new Map<string, number>();
|
||||||
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
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 {
|
return {
|
||||||
total,
|
total,
|
||||||
active: map.get('active') ?? 0,
|
active: map.get('active') ?? 0,
|
||||||
pending: map.get('pending') ?? 0,
|
pending: map.get('pending') ?? 0,
|
||||||
|
onboarding,
|
||||||
suspended: map.get('suspended') ?? 0,
|
suspended: map.get('suspended') ?? 0,
|
||||||
blacklisted: map.get('blacklisted') ?? 0,
|
blacklisted: map.get('blacklisted') ?? 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -372,6 +372,9 @@ export class CompaniesService {
|
|||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
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;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +965,28 @@ export class CompaniesService {
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
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
|
// A reference number is only minted the first time a profile is approved
|
||||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||||
const patch: Partial<CompanyProfile> = { status };
|
const patch: Partial<CompanyProfile> = { status };
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export class CompanyStatsResponseDto {
|
export class CompanyStatsResponseDto {
|
||||||
total!: number;
|
total!: number;
|
||||||
active!: number;
|
active!: number;
|
||||||
|
/** Submitted applications awaiting review. Excludes drafts. */
|
||||||
pending!: number;
|
pending!: number;
|
||||||
|
/** Self-registered companies still working through the onboarding wizard. */
|
||||||
|
onboarding!: number;
|
||||||
suspended!: number;
|
suspended!: number;
|
||||||
blacklisted!: number;
|
blacklisted!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
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 { Transform } from "class-transformer";
|
||||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||||
|
|
||||||
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(Object.values(CompanyStatus))
|
@IsIn(Object.values(CompanyStatus))
|
||||||
status?: 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;
|
attributes?: Record<string, any> | null;
|
||||||
profiles?: ResponseExternalProfileDto[];
|
profiles?: ResponseExternalProfileDto[];
|
||||||
companyProfiles?: ResponseCompanyProfileDto[];
|
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;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
|
|||||||
this.companyProfiles = company.companyProfiles?.map(
|
this.companyProfiles = company.companyProfiles?.map(
|
||||||
(p) => new ResponseCompanyProfileDto(p),
|
(p) => new ResponseCompanyProfileDto(p),
|
||||||
);
|
);
|
||||||
|
this.onboardingCompleted = company.profiles
|
||||||
|
? company.profiles.length === 0 ||
|
||||||
|
company.profiles.some((p) => p.onboardingCompleted)
|
||||||
|
: undefined;
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
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 { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -1053,7 +1054,7 @@ export class ContractBookingService {
|
|||||||
bc.quantity = line.quantity;
|
bc.quantity = line.quantity;
|
||||||
bc.containerTypeId = ct.id;
|
bc.containerTypeId = ct.id;
|
||||||
bc.containerType = ct;
|
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(
|
bc.totalVgmTons = (line.units ?? []).reduce(
|
||||||
(sum, u) => sum + Number(u.vgmTons ?? 0),
|
(sum, u) => sum + Number(u.vgmTons ?? 0),
|
||||||
0,
|
0,
|
||||||
@@ -1513,7 +1514,7 @@ export class ContractBookingService {
|
|||||||
: 0,
|
: 0,
|
||||||
vgmPerUnitTons: vgmPerUnit,
|
vgmPerUnitTons: vgmPerUnit,
|
||||||
totalVgmTons: totalVgm,
|
totalVgmTons: totalVgm,
|
||||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
|
||||||
isOverweight: false,
|
isOverweight: false,
|
||||||
overweightExcessTons: null,
|
overweightExcessTons: null,
|
||||||
} as Partial<BookingContainer>),
|
} as Partial<BookingContainer>),
|
||||||
@@ -1651,7 +1652,7 @@ export class ContractBookingService {
|
|||||||
: 0,
|
: 0,
|
||||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}) as Booking;
|
}) as Booking;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
WsAuthService,
|
WsAuthService,
|
||||||
NotificationInboxService,
|
NotificationInboxService,
|
||||||
],
|
],
|
||||||
exports: [NotificationInboxService],
|
// WsAuthService is reused by the support-chat gateway for handshake auth.
|
||||||
|
exports: [NotificationInboxService, WsAuthService],
|
||||||
})
|
})
|
||||||
export class NotificationInboxModule {}
|
export class NotificationInboxModule {}
|
||||||
|
|||||||
@@ -190,12 +190,16 @@ export class PaymentService {
|
|||||||
*/
|
*/
|
||||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
referenceId: input.referenceId,
|
referenceId: input.referenceId,
|
||||||
orderRef: input.orderRef,
|
orderRef: input.orderRef,
|
||||||
amountMinor: input.amountMinor,
|
// amountMinor: input.amountMinor,
|
||||||
|
amountMinor:1,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
provider: input.method as ProviderMethod,
|
provider: input.method as ProviderMethod,
|
||||||
platform: input.platform,
|
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')
|
@Get('next-range')
|
||||||
@RuleEngineView('priority-configs')
|
@RuleEngineView('priority-configs')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary: 'Where the next contiguous range for a type (and currency) must start',
|
||||||
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
|
|
||||||
})
|
})
|
||||||
nextRange(
|
nextRange(
|
||||||
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
@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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateContainerTypeDto {
|
export class CreateContainerTypeDto {
|
||||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||||
@@ -14,12 +13,6 @@ export class CreateContainerTypeDto {
|
|||||||
@Max(40)
|
@Max(40)
|
||||||
sizeFt!: number;
|
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' })
|
@ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@@ -37,6 +38,31 @@ export class CreateRateDto {
|
|||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: INTERCITY_KINDS,
|
||||||
|
description:
|
||||||
|
'Whether an intercity rate covers containers or bulk. Required when appliesTo = INTERCITY; ignored otherwise. Not stored — it selects the INTERCITY_CONTAINER / INTERCITY_BULK rate type.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...INTERCITY_KINDS])
|
||||||
|
intercityKind?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationYardId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CURRENCIES })
|
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CURRENCIES])
|
@IsIn([...CURRENCIES])
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export class CreateYardDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
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' })
|
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@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 })
|
@Column({ name: 'size_ft', type: 'smallint', nullable: true })
|
||||||
sizeFt!: number;
|
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 })
|
@Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
|
||||||
isReefer!: boolean;
|
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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { CargoType } from './cargo-type.entity';
|
import { CargoType } from './cargo-type.entity';
|
||||||
import { ContainerType } from './container-type.entity';
|
import { ContainerType } from './container-type.entity';
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
export const RATE_TYPES = [
|
export const RATE_TYPES = [
|
||||||
'CONTAINER_IMPORT',
|
'CONTAINER_IMPORT',
|
||||||
@@ -91,6 +92,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['trigger'])
|
@Index(['trigger'])
|
||||||
|
@Index(['originYardId'])
|
||||||
|
@Index(['destinationYardId'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||||
rateType!: RateType;
|
rateType!: RateType;
|
||||||
@@ -118,6 +121,26 @@ export class Rate extends BaseEntity {
|
|||||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||||||
|
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||||||
|
* required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
|
||||||
|
* `CK_rates_yard_scope` DB constraint enforces both halves of that.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||||
|
originYardId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||||||
|
@JoinColumn({ name: 'origin_yard_id' })
|
||||||
|
originYard?: Yard | null;
|
||||||
|
|
||||||
|
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||||||
|
@JoinColumn({ name: 'destination_yard_id' })
|
||||||
|
destinationYard?: Yard | null;
|
||||||
|
|
||||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
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 })
|
@Column({ name: 'display_order', type: 'int', default: 1 })
|
||||||
displayOrder!: number;
|
displayOrder!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface IRatesRepository {
|
|||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
}): Promise<Rate | null>;
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
}): Promise<Rate | null> {
|
}): Promise<Rate | null> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
@@ -59,6 +61,18 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
} else {
|
} else {
|
||||||
qb.andWhere('rate.trade_direction IS NULL');
|
qb.andWhere('rate.trade_direction IS NULL');
|
||||||
}
|
}
|
||||||
|
if (pattern.originYardId) {
|
||||||
|
qb.andWhere('rate.origin_yard_id = :originYardId', { originYardId: pattern.originYardId });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.origin_yard_id IS NULL');
|
||||||
|
}
|
||||||
|
if (pattern.destinationYardId) {
|
||||||
|
qb.andWhere('rate.destination_yard_id = :destinationYardId', {
|
||||||
|
destinationYardId: pattern.destinationYardId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
qb.andWhere('rate.destination_yard_id IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
return qb.getOne();
|
return qb.getOne();
|
||||||
}
|
}
|
||||||
@@ -75,6 +89,10 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
|
// The admin table shows the leg a base-freight rate prices — without the
|
||||||
|
// yards joined the route columns have only ids to render.
|
||||||
|
.leftJoinAndSelect('rate.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
|
||||||
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
if (query.status) {
|
if (query.status) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller';
|
|||||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||||
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.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 { RatesController } from './controllers/rates.controller';
|
||||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||||
import { ShippingLinesController } from './controllers/shipping-lines.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 { ContainerType } from './entities/container-type.entity';
|
||||||
import { PriorityConfig } from './entities/priority-config.entity';
|
import { PriorityConfig } from './entities/priority-config.entity';
|
||||||
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.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 { Rate } from './entities/rate.entity';
|
||||||
import { ServiceType } from './entities/service-type.entity';
|
import { ServiceType } from './entities/service-type.entity';
|
||||||
import { ShippingLine } from './entities/shipping-line.entity';
|
import { ShippingLine } from './entities/shipping-line.entity';
|
||||||
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
||||||
import { Yard } from './entities/yard.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 { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.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 { ContainerTypesService } from './services/container-types.service';
|
||||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||||
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.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 { RatesService } from './services/rates.service';
|
||||||
import { ServiceTypesService } from './services/service-types.service';
|
import { ServiceTypesService } from './services/service-types.service';
|
||||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||||
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||||
import { YardsService } from './services/yards.service';
|
import { YardsService } from './services/yards.service';
|
||||||
|
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||||
|
|
||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
@@ -72,9 +77,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ContainerType,
|
ContainerType,
|
||||||
PriorityConfig,
|
PriorityConfig,
|
||||||
PriorityRuleChangeRequest,
|
PriorityRuleChangeRequest,
|
||||||
|
RateChangeRequest,
|
||||||
ServiceType,
|
ServiceType,
|
||||||
WeightLimitRule,
|
WeightLimitRule,
|
||||||
Yard,
|
Yard,
|
||||||
|
YardFacility,
|
||||||
ShippingLine,
|
ShippingLine,
|
||||||
Rate,
|
Rate,
|
||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
@@ -91,6 +98,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ContainerTypesController,
|
ContainerTypesController,
|
||||||
PriorityConfigsController,
|
PriorityConfigsController,
|
||||||
PriorityRuleChangeRequestsController,
|
PriorityRuleChangeRequestsController,
|
||||||
|
RateChangeRequestsController,
|
||||||
ServiceTypesController,
|
ServiceTypesController,
|
||||||
WeightLimitRulesController,
|
WeightLimitRulesController,
|
||||||
YardsController,
|
YardsController,
|
||||||
@@ -121,9 +129,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ContainerTypesService,
|
ContainerTypesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
PriorityRuleChangeRequestsService,
|
PriorityRuleChangeRequestsService,
|
||||||
|
RateChangeRequestsService,
|
||||||
ServiceTypesService,
|
ServiceTypesService,
|
||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
YardFacilitiesService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
@@ -138,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
YardFacilitiesService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export class ContainerTypesService {
|
|||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
sizeFt: dto.sizeFt,
|
sizeFt: dto.sizeFt,
|
||||||
wagonsPerUnit: dto.wagonsPerUnit,
|
|
||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
isOpenTop: dto.isOpenTop ?? false,
|
isOpenTop: dto.isOpenTop ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
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
|
* 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
|
* CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range
|
||||||
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
* must start at the lowest uncovered wagon count. There is no upper ceiling.
|
||||||
* CURRENCY 35, CUSTOMS 15.
|
|
||||||
*/
|
*/
|
||||||
describe('PriorityConfigsService range validation', () => {
|
describe('PriorityConfigsService range validation', () => {
|
||||||
const rule = (
|
const rule = (
|
||||||
@@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => {
|
|||||||
).rejects.toThrow(/overlaps existing rule/);
|
).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(
|
await expect(
|
||||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }),
|
||||||
).rejects.toThrow(/may not exceed 50/);
|
).resolves.toBeUndefined();
|
||||||
await expect(
|
await expect(
|
||||||
attempt(serviceWith([]), {
|
attempt(serviceWith([]), {
|
||||||
type: 'CURRENCY',
|
type: 'CURRENCY',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
minWagonCount: 1,
|
minWagonCount: 1,
|
||||||
maxWagonCount: 36,
|
maxWagonCount: 5000,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/may not exceed 35/);
|
).resolves.toBeUndefined();
|
||||||
await expect(
|
await expect(
|
||||||
attempt(serviceWith([]), {
|
attempt(serviceWith([]), {
|
||||||
type: 'CUSTOMS',
|
type: 'CUSTOMS',
|
||||||
minWagonCount: 1,
|
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(
|
await expect(
|
||||||
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||||||
minWagonCount: 51,
|
minWagonCount: 51,
|
||||||
maxWagonCount: 51,
|
maxWagonCount: 120,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/may not exceed 50/);
|
).resolves.toBeUndefined();
|
||||||
await expect(
|
await expect(
|
||||||
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||||||
type: 'CUSTOMS',
|
type: 'CUSTOMS',
|
||||||
minWagonCount: 1,
|
minWagonCount: 16,
|
||||||
maxWagonCount: 1,
|
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 () => {
|
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 () => {
|
it('reports the next-range prefill for the form', async () => {
|
||||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({
|
await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 });
|
||||||
nextMin: 6,
|
// Past the old CUSTOMS cap of 15 the chain simply continues.
|
||||||
maxCap: 50,
|
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
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({
|
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||||||
nextMin: 1,
|
nextMin: 1,
|
||||||
maxCap: 35,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,28 +10,19 @@ import {
|
|||||||
} from '../interfaces/priority-configs.repository.interface';
|
} from '../interfaces/priority-configs.repository.interface';
|
||||||
import { DisplayOrderService } from './display-order.service';
|
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
|
* 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(
|
function nextRangeStart(
|
||||||
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||||
): number | null {
|
): number {
|
||||||
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
|
|
||||||
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
||||||
let next = 1;
|
let next = 1;
|
||||||
for (const r of sorted) {
|
for (const r of sorted) {
|
||||||
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
||||||
next = Math.max(next, r.maxWagonCount + 1);
|
next = Math.max(next, r.maxWagonCount + 1);
|
||||||
}
|
}
|
||||||
if (cap != null && next > cap) return null;
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +93,8 @@ export class PriorityConfigsService {
|
|||||||
* - ranges never overlap — a booking matches at most one rule per type;
|
* - 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
|
* - 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
|
* 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);
|
* 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.
|
* There is no upper ceiling — max wagon count is unbounded.
|
||||||
* Ranges are inclusive on both ends.
|
* Ranges are inclusive on both ends.
|
||||||
*/
|
*/
|
||||||
async assertNoRangeCollision(input: {
|
async assertNoRangeCollision(input: {
|
||||||
@@ -118,14 +109,6 @@ export class PriorityConfigsService {
|
|||||||
'Min wagon count cannot be greater than max wagon count',
|
'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 = (
|
const siblings = (
|
||||||
await this.repository.findAll({ where: { type: input.type } })
|
await this.repository.findAll({ where: { type: input.type } })
|
||||||
).filter(
|
).filter(
|
||||||
@@ -142,12 +125,6 @@ export class PriorityConfigsService {
|
|||||||
const currentStart = input.excludeId
|
const currentStart = input.excludeId
|
||||||
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
||||||
: 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 (
|
if (
|
||||||
input.minWagonCount !== expectedStart &&
|
input.minWagonCount !== expectedStart &&
|
||||||
input.minWagonCount !== currentStart
|
input.minWagonCount !== currentStart
|
||||||
@@ -174,21 +151,21 @@ export class PriorityConfigsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the next range for a type/currency must start, and the type's
|
* Where the next range for a type/currency must start — feeds the create
|
||||||
* ceiling — feeds the create form so the min field is auto-filled and
|
* form so the min field is auto-filled and locked. Always a number: the
|
||||||
* locked. `nextMin` is null when the chain already covers 1..cap.
|
* chain has no ceiling, so another range always fits.
|
||||||
*/
|
*/
|
||||||
async nextRange(
|
async nextRange(
|
||||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||||
currency?: string | null,
|
currency?: string | null,
|
||||||
): Promise<{ nextMin: number | null; maxCap: number }> {
|
): Promise<{ nextMin: number }> {
|
||||||
const siblings = (
|
const siblings = (
|
||||||
await this.repository.findAll({ where: { type } })
|
await this.repository.findAll({ where: { type } })
|
||||||
).filter(
|
).filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
|
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> {
|
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}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
@@ -14,12 +14,24 @@ import { Rate } from '../entities/rate.entity';
|
|||||||
import { deriveRateType } from '../entities/rate-type.util';
|
import { deriveRateType } from '../entities/rate-type.util';
|
||||||
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||||
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
|
|
||||||
|
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||||
|
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
||||||
|
|
||||||
|
/** The yard pair a rate scopes to, already validated against its direction. */
|
||||||
|
interface YardScope {
|
||||||
|
originYardId: string | null;
|
||||||
|
destinationYardId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RatesService {
|
export class RatesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RATES_REPOSITORY)
|
@Inject(RATES_REPOSITORY)
|
||||||
private readonly repository: IRatesRepository,
|
private readonly repository: IRatesRepository,
|
||||||
|
@Inject(YARDS_REPOSITORY)
|
||||||
|
private readonly yardsRepository: IYardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List rates — standard paginated envelope with server-side search. */
|
/** List rates — standard paginated envelope with server-side search. */
|
||||||
@@ -62,6 +74,136 @@ export class RatesService {
|
|||||||
return requestedUnit;
|
return requestedUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
|
||||||
|
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||||
|
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which country each end of the leg must sit in, given what the rate is for.
|
||||||
|
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||||
|
* rails inland, export is the reverse, and intercity stays inside Ethiopia.
|
||||||
|
*/
|
||||||
|
private expectedYardCountries(
|
||||||
|
appliesTo: Rate['appliesTo'],
|
||||||
|
tradeDirection: string | null,
|
||||||
|
): { origin: YardCountry; destination: YardCountry } {
|
||||||
|
if (appliesTo === 'INTERCITY') {
|
||||||
|
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||||
|
}
|
||||||
|
return tradeDirection === 'EXPORT'
|
||||||
|
? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI }
|
||||||
|
: { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalise the leg a rate prices.
|
||||||
|
*
|
||||||
|
* Base freight must name both yards and they must match the direction, so a
|
||||||
|
* "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything
|
||||||
|
* else (surcharges, first/last mile) is route-agnostic and has its yards
|
||||||
|
* cleared, mirroring how container/cargo scope is cleared for surcharges.
|
||||||
|
*/
|
||||||
|
private async resolveYardScope(input: {
|
||||||
|
appliesTo: Rate['appliesTo'];
|
||||||
|
trigger: Rate['trigger'];
|
||||||
|
tradeDirection: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
}): Promise<YardScope> {
|
||||||
|
const { appliesTo, trigger, tradeDirection } = input;
|
||||||
|
if (!this.isBaseFreight(appliesTo, trigger)) {
|
||||||
|
return { originYardId: null, destinationYardId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const originYardId = input.originYardId ?? null;
|
||||||
|
const destinationYardId = input.destinationYardId ?? null;
|
||||||
|
if (!originYardId || !destinationYardId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (originYardId === destinationYardId) {
|
||||||
|
throw new BadRequestException('Origin and destination yard must be different.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [origin, destination] = await Promise.all([
|
||||||
|
this.yardsRepository.findById(originYardId),
|
||||||
|
this.yardsRepository.findById(destinationYardId),
|
||||||
|
]);
|
||||||
|
if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||||
|
if (!destination) {
|
||||||
|
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = this.expectedYardCountries(appliesTo, tradeDirection);
|
||||||
|
if (origin.country !== expected.origin || destination.country !== expected.destination) {
|
||||||
|
const shape =
|
||||||
|
appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`;
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` +
|
||||||
|
`${origin.country} and ${destination.label} is in ${destination.country}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { originYardId, destinationYardId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard the scope fields a base-freight category needs before we derive its
|
||||||
|
* rateType: import/export must say which, and intercity must say whether it
|
||||||
|
* carries containers or bulk (the two price differently and an unstated kind
|
||||||
|
* would silently file the rate as one of them).
|
||||||
|
*/
|
||||||
|
private assertScopeCoherent(input: {
|
||||||
|
appliesTo: Rate['appliesTo'];
|
||||||
|
trigger: Rate['trigger'];
|
||||||
|
tradeDirection: string | null;
|
||||||
|
intercityKind: string | null;
|
||||||
|
containerTypeId: string | null;
|
||||||
|
cargoTypeId: string | null;
|
||||||
|
}): void {
|
||||||
|
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
||||||
|
const { containerTypeId, cargoTypeId } = input;
|
||||||
|
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||||
|
|
||||||
|
if (appliesTo === 'INTERCITY') {
|
||||||
|
if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An intercity rate must say whether it covers containers or bulk.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The scope field has to agree with the kind, or the rate would advertise
|
||||||
|
// one cargo kind and narrow by the other.
|
||||||
|
if (intercityKind === 'CONTAINER' && cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An intercity container rate cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (intercityKind === 'BULK' && containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An intercity bulk rate cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a rate covers bulk cargo — the flag `deriveRateType` splits
|
||||||
|
* INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind
|
||||||
|
* explicitly; for BULK/CONTAINER the category already says it.
|
||||||
|
*/
|
||||||
|
private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean {
|
||||||
|
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||||
@@ -73,12 +215,14 @@ export class RatesService {
|
|||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
|
originYardId: string | null;
|
||||||
|
destinationYardId: string | null;
|
||||||
ignoreId?: string;
|
ignoreId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const existing = await this.repository.findByPattern(pattern);
|
const existing = await this.repository.findByPattern(pattern);
|
||||||
if (existing && existing.id !== pattern.ignoreId) {
|
if (existing && existing.id !== pattern.ignoreId) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
|
'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,17 +236,45 @@ export class RatesService {
|
|||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||||
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
|
// its yard pair already says where it runs.
|
||||||
|
const tradeDirection =
|
||||||
|
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
||||||
|
|
||||||
|
const intercityKind = dto.intercityKind ?? null;
|
||||||
|
this.assertScopeCoherent({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection,
|
||||||
|
intercityKind,
|
||||||
|
containerTypeId,
|
||||||
|
cargoTypeId,
|
||||||
|
});
|
||||||
|
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection,
|
||||||
|
originYardId: dto.originYardId,
|
||||||
|
destinationYardId: dto.destinationYardId,
|
||||||
|
});
|
||||||
|
|
||||||
const rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: Boolean(cargoTypeId),
|
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||||
});
|
});
|
||||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||||
|
|
||||||
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
|
await this.assertNoDuplicatePattern({
|
||||||
|
rateType,
|
||||||
|
rateUnit,
|
||||||
|
containerTypeId,
|
||||||
|
cargoTypeId,
|
||||||
|
tradeDirection,
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
});
|
||||||
|
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
@@ -111,6 +283,8 @@ export class RatesService {
|
|||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
currency: dto.currency ?? 'USD',
|
currency: dto.currency ?? 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
@@ -119,12 +293,62 @@ export class RatesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a DRAFT rate. */
|
/**
|
||||||
|
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||||
|
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||||
|
*/
|
||||||
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (existing.status !== 'DRAFT') {
|
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 updates: Partial<Rate> = {};
|
||||||
|
|
||||||
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
||||||
@@ -144,21 +368,52 @@ export class RatesService {
|
|||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection = isSurcharge
|
const tradeDirection =
|
||||||
? null
|
isSurcharge || appliesTo === 'INTERCITY'
|
||||||
: dto.tradeDirection !== undefined
|
? null
|
||||||
? dto.tradeDirection
|
: dto.tradeDirection !== undefined
|
||||||
: existing.tradeDirection;
|
? dto.tradeDirection
|
||||||
|
: existing.tradeDirection;
|
||||||
|
|
||||||
updates.containerTypeId = containerTypeId ?? null;
|
updates.containerTypeId = containerTypeId ?? null;
|
||||||
updates.cargoTypeId = cargoTypeId ?? null;
|
updates.cargoTypeId = cargoTypeId ?? null;
|
||||||
updates.tradeDirection = tradeDirection ?? null;
|
updates.tradeDirection = tradeDirection ?? null;
|
||||||
|
|
||||||
|
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||||
|
// has — read back off its rateType, the only place it is recorded.
|
||||||
|
const intercityKind =
|
||||||
|
dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER');
|
||||||
|
|
||||||
|
this.assertScopeCoherent({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection: updates.tradeDirection,
|
||||||
|
intercityKind,
|
||||||
|
containerTypeId: updates.containerTypeId,
|
||||||
|
cargoTypeId: updates.cargoTypeId,
|
||||||
|
});
|
||||||
|
// Re-validate the leg: changing direction can invalidate a yard pair that
|
||||||
|
// was legal under the old one (an import route is not an export route).
|
||||||
|
const yardScope = await this.resolveYardScope({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection: updates.tradeDirection,
|
||||||
|
originYardId:
|
||||||
|
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||||||
|
destinationYardId:
|
||||||
|
dto.destinationYardId !== undefined
|
||||||
|
? dto.destinationYardId
|
||||||
|
: existing.destinationYardId,
|
||||||
|
});
|
||||||
|
updates.originYardId = yardScope.originYardId;
|
||||||
|
updates.destinationYardId = yardScope.destinationYardId;
|
||||||
|
|
||||||
// Keep the derived rateType in sync with whatever changed.
|
// Keep the derived rateType in sync with whatever changed.
|
||||||
const rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: Boolean(cargoTypeId),
|
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||||
});
|
});
|
||||||
updates.rateType = rateType;
|
updates.rateType = rateType;
|
||||||
|
|
||||||
@@ -174,14 +429,14 @@ export class RatesService {
|
|||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
|
originYardId: updates.originYardId,
|
||||||
|
destinationYardId: updates.destinationYardId,
|
||||||
ignoreId: id,
|
ignoreId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
const updated = await this.repository.update(id, updates);
|
return updates;
|
||||||
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Submit a DRAFT rate for CEO approval. */
|
/** 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,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
|
hasFacility: dto.hasFacility ?? false,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class ListConversationsQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: "Search company name." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Keep only threads with unread messages.",
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === true || value === "true")
|
||||||
|
@IsBoolean()
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class SendMessageDto implements ISendSupportMessageDto {
|
||||||
|
@ApiProperty({ description: "Message text." })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(4000)
|
||||||
|
body!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StartSupportConversationDto as IStartSupportConversationDto } from "@edr/types";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
/** Agent opens the thread with a company before sending the first message. */
|
||||||
|
export class StartConversationDto implements IStartSupportConversationDto {
|
||||||
|
@ApiProperty({ description: "Customer company to chat with." })
|
||||||
|
@IsUUID()
|
||||||
|
companyId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single support thread for a customer **company**. Any portal user of that
|
||||||
|
* company sees and continues it; backoffice agents work a shared inbox. Either
|
||||||
|
* side may open it — whoever sends the first message — and it has no lifecycle:
|
||||||
|
* no status, no resolve, no close.
|
||||||
|
*
|
||||||
|
* The unique index on `company_id` is what enforces one-thread-per-company; the
|
||||||
|
* get-or-create path relies on it to settle races. Last-message fields are
|
||||||
|
* denormalized so the inbox list can sort and preview without joining
|
||||||
|
* `support_messages`. Read cursors are per-side (shared across a company's
|
||||||
|
* users) — unread = messages from the other role newer than the side's cursor.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "support_conversations" })
|
||||||
|
@Index("IDX_SUPPORT_CONV_COMPANY", ["companyId"], {
|
||||||
|
unique: true,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
})
|
||||||
|
@Index("IDX_SUPPORT_CONV_LASTMSG", ["lastMessageAt"])
|
||||||
|
export class SupportConversation extends BaseEntity {
|
||||||
|
@Column({ name: "company_id", type: "uuid" })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
/** Denormalized company name for the agent inbox (resolved at creation). */
|
||||||
|
@Column({ name: "company_name", type: "varchar", length: 200, nullable: true })
|
||||||
|
companyName?: string | null;
|
||||||
|
|
||||||
|
/** Null when an agent opened the thread — no customer created it. */
|
||||||
|
@Column({ name: "created_by_user_id", type: "uuid", nullable: true })
|
||||||
|
createdByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "last_message_at", type: "timestamptz", nullable: true })
|
||||||
|
lastMessageAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "last_message_preview", type: "varchar", length: 280, nullable: true })
|
||||||
|
lastMessagePreview?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "last_message_author_role",
|
||||||
|
type: "varchar",
|
||||||
|
length: 12,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
lastMessageAuthorRole?: SupportAuthorRole | null;
|
||||||
|
|
||||||
|
@Column({ name: "customer_last_read_at", type: "timestamptz", nullable: true })
|
||||||
|
customerLastReadAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "agent_last_read_at", type: "timestamptz", nullable: true })
|
||||||
|
agentLastReadAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
|
/** A single text message inside a {@link SupportConversation}. */
|
||||||
|
@Entity({ schema: "freight", name: "support_messages" })
|
||||||
|
@Index("IDX_SUPPORT_MSG_CONV_CREATED", ["conversationId", "createdAt"])
|
||||||
|
export class SupportMessage extends BaseEntity {
|
||||||
|
@Column({ name: "conversation_id", type: "uuid" })
|
||||||
|
conversationId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "author_user_id", type: "uuid" })
|
||||||
|
authorUserId!: string;
|
||||||
|
|
||||||
|
@Column({ name: "author_role", type: "varchar", length: 12 })
|
||||||
|
authorRole!: SupportAuthorRole;
|
||||||
|
|
||||||
|
/** Display name captured at send time (best-effort). */
|
||||||
|
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||||
|
authorName?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "body", type: "text" })
|
||||||
|
body!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
|
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||||
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
|
/** Backoffice (agent) support-chat endpoints. Shared inbox over all companies. */
|
||||||
|
@ApiTags("support-chat-agent")
|
||||||
|
@Controller("support/agent")
|
||||||
|
export class SupportChatAgentController {
|
||||||
|
constructor(private readonly service: SupportChatService) {}
|
||||||
|
|
||||||
|
@Get("conversations")
|
||||||
|
@ApiOperation({ summary: "List all support threads (shared inbox)" })
|
||||||
|
list(@Query() query: ListConversationsQueryDto) {
|
||||||
|
return this.service.listForAgents(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("conversations")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Start chatting with a company (returns the thread if one exists)",
|
||||||
|
})
|
||||||
|
start(@Body() body: StartConversationDto) {
|
||||||
|
return this.service.startWithCompany(body.companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("conversations/:id/messages")
|
||||||
|
@ApiOperation({ summary: "List messages in a thread" })
|
||||||
|
messages(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.getMessages(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("conversations/:id/messages")
|
||||||
|
@ApiOperation({ summary: "Reply as an agent" })
|
||||||
|
send(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: SendMessageDto,
|
||||||
|
) {
|
||||||
|
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("conversations/:id/read")
|
||||||
|
@ApiOperation({ summary: "Mark a thread read (agent side)" })
|
||||||
|
read(
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
return this.service.markAgentRead(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("unread-count")
|
||||||
|
@ApiOperation({ summary: "Count unread threads (agent side)" })
|
||||||
|
unread(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.service.unreadCount(
|
||||||
|
SupportAuthorRole.AGENT,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { SendMessageDto } from "./dto/send-message.dto";
|
||||||
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal (customer) support-chat endpoints. The caller's company has exactly one
|
||||||
|
* thread, so these are addressed as a singleton — no conversation id on the wire,
|
||||||
|
* and nothing for a portal user to pick between.
|
||||||
|
*/
|
||||||
|
@ApiTags("support-chat")
|
||||||
|
@Controller("support")
|
||||||
|
export class SupportChatController {
|
||||||
|
constructor(private readonly service: SupportChatService) {}
|
||||||
|
|
||||||
|
@Get("conversation")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "My company's support thread (null until someone speaks)",
|
||||||
|
})
|
||||||
|
conversation(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.service.getCustomerConversation(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("conversation/messages")
|
||||||
|
@ApiOperation({ summary: "Messages in my company's support thread" })
|
||||||
|
messages(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("conversation/messages")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Send a message as the customer, opening the thread if needed",
|
||||||
|
})
|
||||||
|
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
|
||||||
|
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("conversation/read")
|
||||||
|
@ApiOperation({ summary: "Mark my company's thread read (customer side)" })
|
||||||
|
read(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.service.markCustomerRead(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("unread-count")
|
||||||
|
@ApiOperation({ summary: "Count my unread support messages" })
|
||||||
|
unread(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.service.unreadCount(
|
||||||
|
SupportAuthorRole.CUSTOMER,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
SUPPORT_CHAT_WS_EVENTS,
|
||||||
|
SUPPORT_CHAT_WS_NAMESPACE,
|
||||||
|
SupportConversationDto,
|
||||||
|
SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
OnGatewayConnection,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
|
||||||
|
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||||
|
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||||
|
import { WsAuthService } from "../notification-inbox/ws-auth.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client push for support chat. Clients only *listen* (no
|
||||||
|
* `@SubscribeMessage`); the handshake is authenticated in `handleConnection`
|
||||||
|
* (reusing the notification module's {@link WsAuthService}). Each socket joins a
|
||||||
|
* room based on its side:
|
||||||
|
* - backoffice staff → the shared `backoffice` room (see every conversation).
|
||||||
|
* - portal users → their `company:<companyId>` room (their tickets only).
|
||||||
|
*
|
||||||
|
* A message is emitted to *both* the company room and the backoffice room so the
|
||||||
|
* customer thread, the sender's echo, and every other agent's inbox update live.
|
||||||
|
*/
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: SUPPORT_CHAT_WS_NAMESPACE,
|
||||||
|
cors: { origin: true, credentials: true },
|
||||||
|
})
|
||||||
|
export class SupportChatGateway implements OnGatewayConnection {
|
||||||
|
private readonly logger = new Logger(SupportChatGateway.name);
|
||||||
|
|
||||||
|
private static readonly BACKOFFICE_ROOM = "backoffice";
|
||||||
|
|
||||||
|
@WebSocketServer()
|
||||||
|
private readonly server!: Server;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly wsAuth: WsAuthService,
|
||||||
|
private readonly backoffice: BackofficeService,
|
||||||
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleConnection(socket: Socket): Promise<void> {
|
||||||
|
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.debug(`Rejected support-chat handshake ${socket.id}`);
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.data.userId = userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
|
||||||
|
if (staffIds.includes(userId)) {
|
||||||
|
await socket.join(SupportChatGateway.BACKOFFICE_ROOM);
|
||||||
|
socket.data.side = "AGENT";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Staff lookup failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.externalProfiles.findByUserId(userId);
|
||||||
|
if (profile?.companyId) {
|
||||||
|
await socket.join(this.companyRoom(profile.companyId));
|
||||||
|
socket.data.side = "CUSTOMER";
|
||||||
|
socket.data.companyId = profile.companyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a new message + updated conversation to the company and backoffice rooms. */
|
||||||
|
emitMessage(
|
||||||
|
companyId: string,
|
||||||
|
conversation: SupportConversationDto,
|
||||||
|
message: SupportMessageDto,
|
||||||
|
): void {
|
||||||
|
const payload = { conversation, message };
|
||||||
|
for (const room of this.targetRooms(companyId)) {
|
||||||
|
const to = this.server.to(room);
|
||||||
|
to.emit(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, payload);
|
||||||
|
to.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a conversation metadata change (e.g. status) to both rooms. */
|
||||||
|
emitConversationUpdated(
|
||||||
|
companyId: string,
|
||||||
|
conversation: SupportConversationDto,
|
||||||
|
): void {
|
||||||
|
for (const room of this.targetRooms(companyId)) {
|
||||||
|
this.server
|
||||||
|
.to(room)
|
||||||
|
.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetRooms(companyId: string): string[] {
|
||||||
|
return [this.companyRoom(companyId), SupportChatGateway.BACKOFFICE_ROOM];
|
||||||
|
}
|
||||||
|
|
||||||
|
private companyRoom(companyId: string): string {
|
||||||
|
return `company:${companyId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(socket: Socket): string | undefined {
|
||||||
|
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||||
|
if (authToken) return authToken;
|
||||||
|
|
||||||
|
const queryToken = socket.handshake.query?.token;
|
||||||
|
if (typeof queryToken === "string") return queryToken;
|
||||||
|
|
||||||
|
const header = socket.handshake.headers?.authorization;
|
||||||
|
if (header?.startsWith("Bearer ")) return header.slice(7);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||||
|
import { SupportChatController } from "./support-chat.controller";
|
||||||
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
|
import { SupportChatService } from "./support-chat.service";
|
||||||
|
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||||
|
import { SupportMessageRepository } from "./support-message.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([SupportConversation, SupportMessage]),
|
||||||
|
// ExternalProfileRepository — company lookup + ownership checks.
|
||||||
|
// CompaniesService — resolve the company an agent opens a thread with.
|
||||||
|
CompaniesModule,
|
||||||
|
// BackofficeService.getAllCurrentEmployeeUserIds — staff room membership.
|
||||||
|
BackofficeModule,
|
||||||
|
// WsAuthService — reused handshake authentication for the gateway.
|
||||||
|
NotificationInboxModule,
|
||||||
|
],
|
||||||
|
controllers: [SupportChatController, SupportChatAgentController],
|
||||||
|
providers: [
|
||||||
|
SupportConversationRepository,
|
||||||
|
SupportMessageRepository,
|
||||||
|
SupportChatGateway,
|
||||||
|
SupportChatService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SupportChatModule {}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import {
|
||||||
|
SendSupportMessageResult,
|
||||||
|
SupportAuthorRole,
|
||||||
|
SupportConversationDto,
|
||||||
|
SupportConversationListResult,
|
||||||
|
SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { QueryFailedError } from "typeorm";
|
||||||
|
|
||||||
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||||
|
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||||
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
import { SupportChatGateway } from "./support-chat.gateway";
|
||||||
|
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||||
|
import { SupportMessageRepository } from "./support-message.repository";
|
||||||
|
|
||||||
|
interface CustomerContext {
|
||||||
|
companyId: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
authorName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||||
|
const PG_UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportChatService {
|
||||||
|
constructor(
|
||||||
|
private readonly conversations: SupportConversationRepository,
|
||||||
|
private readonly messages: SupportMessageRepository,
|
||||||
|
private readonly gateway: SupportChatGateway,
|
||||||
|
private readonly externalProfiles: ExternalProfileRepository,
|
||||||
|
private readonly companies: CompaniesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---- customer (portal) -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's company thread, or null if nobody has spoken yet. Deliberately
|
||||||
|
* does *not* create: opening the widget shouldn't push an empty thread into
|
||||||
|
* the agent inbox. Creation happens on the first message.
|
||||||
|
*/
|
||||||
|
async getCustomerConversation(
|
||||||
|
userId: string,
|
||||||
|
): Promise<SupportConversationDto | null> {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||||
|
if (!conversation) return null;
|
||||||
|
const unread = await this.messages.unreadCountsByConversation(
|
||||||
|
[conversation.id],
|
||||||
|
SupportAuthorRole.CUSTOMER,
|
||||||
|
);
|
||||||
|
return this.toConversationDto(
|
||||||
|
conversation,
|
||||||
|
unread.get(conversation.id) ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||||
|
if (!conversation) return [];
|
||||||
|
return this.listMessages(conversation.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send as the customer, opening the thread if this is the first message. */
|
||||||
|
async sendAsCustomer(
|
||||||
|
userId: string,
|
||||||
|
body: string,
|
||||||
|
): Promise<SendSupportMessageResult> {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
const conversation = await this.getOrCreate(
|
||||||
|
ctx.companyId,
|
||||||
|
ctx.companyName,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const { conversation: updated, message } = await this.appendMessage(
|
||||||
|
conversation,
|
||||||
|
userId,
|
||||||
|
SupportAuthorRole.CUSTOMER,
|
||||||
|
body,
|
||||||
|
ctx.authorName,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
conversation: this.toConversationDto(updated, 0),
|
||||||
|
message: this.toMessageDto(message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||||
|
if (conversation) {
|
||||||
|
await this.conversations.update(conversation.id, {
|
||||||
|
customerLastReadAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.unreadCount(SupportAuthorRole.CUSTOMER, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- agent (backoffice) ------------------------------------------------
|
||||||
|
|
||||||
|
async listForAgents(
|
||||||
|
query: ListConversationsQueryDto,
|
||||||
|
): Promise<SupportConversationListResult> {
|
||||||
|
const [rows, count] = await this.conversations.listAll(
|
||||||
|
SupportAuthorRole.AGENT,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
return this.buildListResult(rows, count, SupportAuthorRole.AGENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open (or reuse) the thread with a company so an agent can start chatting.
|
||||||
|
* Idempotent — clicking a company that already has a thread just returns it.
|
||||||
|
*/
|
||||||
|
async startWithCompany(companyId: string): Promise<SupportConversationDto> {
|
||||||
|
const company = await this.companies.findCompanyById(companyId);
|
||||||
|
const existing = await this.conversations.findByCompanyId(companyId);
|
||||||
|
const conversation =
|
||||||
|
existing ?? (await this.getOrCreate(companyId, company.name, null));
|
||||||
|
|
||||||
|
const unread = await this.messages.unreadCountsByConversation(
|
||||||
|
[conversation.id],
|
||||||
|
SupportAuthorRole.AGENT,
|
||||||
|
);
|
||||||
|
const dto = this.toConversationDto(
|
||||||
|
conversation,
|
||||||
|
unread.get(conversation.id) ?? 0,
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
// Surface the new thread in every agent's inbox right away.
|
||||||
|
this.gateway.emitConversationUpdated(conversation.companyId, dto);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAsAgent(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
body: string,
|
||||||
|
): Promise<SupportMessageDto> {
|
||||||
|
const conversation = await this.requireConversation(conversationId);
|
||||||
|
const { message } = await this.appendMessage(
|
||||||
|
conversation,
|
||||||
|
userId,
|
||||||
|
SupportAuthorRole.AGENT,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return this.toMessageDto(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAgentRead(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ unreadCount: number }> {
|
||||||
|
await this.requireConversation(conversationId);
|
||||||
|
await this.conversations.update(conversationId, {
|
||||||
|
agentLastReadAt: new Date(),
|
||||||
|
});
|
||||||
|
return this.unreadCount(SupportAuthorRole.AGENT, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shared ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
|
||||||
|
* company owns it (portal route); omit for agents, who see every thread.
|
||||||
|
*/
|
||||||
|
async getMessages(
|
||||||
|
conversationId: string,
|
||||||
|
asCustomerUserId?: string,
|
||||||
|
): Promise<SupportMessageDto[]> {
|
||||||
|
const conversation = await this.requireConversation(conversationId);
|
||||||
|
if (asCustomerUserId) {
|
||||||
|
await this.assertCustomerOwns(conversation, asCustomerUserId);
|
||||||
|
}
|
||||||
|
return this.listMessages(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unreadCount(
|
||||||
|
side: SupportAuthorRole,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ unreadCount: number }> {
|
||||||
|
if (side === SupportAuthorRole.CUSTOMER) {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
return {
|
||||||
|
unreadCount: await this.messages.countUnreadConversations(
|
||||||
|
side,
|
||||||
|
ctx.companyId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { unreadCount: await this.messages.countUnreadConversations(side) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the company's thread or open it. Two first-messages can race here, so
|
||||||
|
* we let the unique index arbitrate and re-read the winner rather than
|
||||||
|
* locking — the loser's insert is the only wasted work.
|
||||||
|
*/
|
||||||
|
private async getOrCreate(
|
||||||
|
companyId: string,
|
||||||
|
companyName: string | null | undefined,
|
||||||
|
createdByUserId: string | null,
|
||||||
|
): Promise<SupportConversation> {
|
||||||
|
const existing = await this.conversations.findByCompanyId(companyId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.conversations.create({
|
||||||
|
companyId,
|
||||||
|
companyName: companyName ?? null,
|
||||||
|
createdByUserId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof QueryFailedError &&
|
||||||
|
(error as QueryFailedError & { code?: string }).code ===
|
||||||
|
PG_UNIQUE_VIOLATION
|
||||||
|
) {
|
||||||
|
const winner = await this.conversations.findByCompanyId(companyId);
|
||||||
|
if (winner) return winner;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listMessages(
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<SupportMessageDto[]> {
|
||||||
|
const rows = await this.messages.listByConversation(conversationId);
|
||||||
|
return rows.map((m) => this.toMessageDto(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||||
|
private async appendMessage(
|
||||||
|
conversation: SupportConversation,
|
||||||
|
userId: string,
|
||||||
|
role: SupportAuthorRole,
|
||||||
|
body: string,
|
||||||
|
authorName?: string | null,
|
||||||
|
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
|
||||||
|
const message = await this.messages.create({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
authorUserId: userId,
|
||||||
|
authorRole: role,
|
||||||
|
authorName: authorName ?? null,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
conversation.lastMessageAt = message.createdAt;
|
||||||
|
conversation.lastMessagePreview = body.slice(0, 280);
|
||||||
|
conversation.lastMessageAuthorRole = role;
|
||||||
|
await this.conversations.update(conversation.id, {
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessagePreview: conversation.lastMessagePreview,
|
||||||
|
lastMessageAuthorRole: role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dto = this.toConversationDto(conversation, 0);
|
||||||
|
this.gateway.emitMessage(
|
||||||
|
conversation.companyId,
|
||||||
|
dto,
|
||||||
|
this.toMessageDto(message),
|
||||||
|
);
|
||||||
|
return { conversation, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildListResult(
|
||||||
|
rows: SupportConversation[],
|
||||||
|
count: number,
|
||||||
|
side: SupportAuthorRole,
|
||||||
|
companyId?: string,
|
||||||
|
): Promise<SupportConversationListResult> {
|
||||||
|
const unreadMap = await this.messages.unreadCountsByConversation(
|
||||||
|
rows.map((r) => r.id),
|
||||||
|
side,
|
||||||
|
);
|
||||||
|
const items = rows.map((r) =>
|
||||||
|
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
|
||||||
|
);
|
||||||
|
const unreadCount = await this.messages.countUnreadConversations(
|
||||||
|
side,
|
||||||
|
companyId,
|
||||||
|
);
|
||||||
|
return { items, count, unreadCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCustomer(userId: string): Promise<CustomerContext> {
|
||||||
|
const profile = await this.externalProfiles.findByUserId(userId);
|
||||||
|
if (!profile?.companyId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"No company profile is linked to this account.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = [profile.firstName, profile.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.trim();
|
||||||
|
return {
|
||||||
|
companyId: profile.companyId,
|
||||||
|
companyName: profile.company?.name ?? null,
|
||||||
|
authorName: name || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCustomerOwns(
|
||||||
|
conversation: SupportConversation,
|
||||||
|
userId: string,
|
||||||
|
): Promise<CustomerContext> {
|
||||||
|
const ctx = await this.resolveCustomer(userId);
|
||||||
|
if (conversation.companyId !== ctx.companyId) {
|
||||||
|
throw new ForbiddenException("This conversation belongs to another company.");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireConversation(id: string): Promise<SupportConversation> {
|
||||||
|
const conversation = await this.conversations.findById(id);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException("Conversation not found.");
|
||||||
|
}
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toConversationDto(
|
||||||
|
c: SupportConversation,
|
||||||
|
unreadCount: number,
|
||||||
|
): SupportConversationDto {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
companyId: c.companyId,
|
||||||
|
companyName: c.companyName ?? null,
|
||||||
|
createdByUserId: c.createdByUserId ?? null,
|
||||||
|
lastMessageAt: c.lastMessageAt
|
||||||
|
? new Date(c.lastMessageAt).toISOString()
|
||||||
|
: null,
|
||||||
|
lastMessagePreview: c.lastMessagePreview ?? null,
|
||||||
|
lastMessageAuthorRole: c.lastMessageAuthorRole ?? null,
|
||||||
|
unreadCount,
|
||||||
|
createdAt: new Date(c.createdAt).toISOString(),
|
||||||
|
updatedAt: new Date(c.updatedAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toMessageDto(m: SupportMessage): SupportMessageDto {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
conversationId: m.conversationId,
|
||||||
|
authorUserId: m.authorUserId,
|
||||||
|
authorRole: m.authorRole,
|
||||||
|
authorName: m.authorName ?? null,
|
||||||
|
body: m.body,
|
||||||
|
createdAt: new Date(m.createdAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
|
|
||||||
|
export interface ListConversationsOptions {
|
||||||
|
search?: string;
|
||||||
|
/** Keep only threads with at least one message the side hasn't read. */
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportConversationRepository extends BaseRepository<SupportConversation> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SupportConversation)
|
||||||
|
repo: Repository<SupportConversation>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The company's thread, or null if neither side has spoken yet. */
|
||||||
|
async findByCompanyId(companyId: string): Promise<SupportConversation | null> {
|
||||||
|
return this.repository.findOne({ where: { companyId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every thread (backoffice shared inbox), most-recently-active first. */
|
||||||
|
async listAll(
|
||||||
|
side: SupportAuthorRole,
|
||||||
|
opts: ListConversationsOptions = {},
|
||||||
|
): Promise<[SupportConversation[], number]> {
|
||||||
|
const page = opts.page && opts.page > 0 ? opts.page : 1;
|
||||||
|
const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder("c")
|
||||||
|
.orderBy("c.last_message_at", "DESC", "NULLS LAST")
|
||||||
|
.addOrderBy("c.created_at", "DESC")
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
if (opts.search?.trim()) {
|
||||||
|
qb.andWhere("c.company_name ILIKE :term", {
|
||||||
|
term: `%${opts.search.trim()}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.unreadOnly) {
|
||||||
|
// Same rule as SupportMessageRepository.baseUnreadQuery: a message from
|
||||||
|
// the other role, newer than this side's cursor. `cursorCol` is chosen
|
||||||
|
// from a closed set below — never caller input.
|
||||||
|
const otherRole =
|
||||||
|
side === SupportAuthorRole.CUSTOMER
|
||||||
|
? SupportAuthorRole.AGENT
|
||||||
|
: SupportAuthorRole.CUSTOMER;
|
||||||
|
const cursorCol =
|
||||||
|
side === SupportAuthorRole.CUSTOMER
|
||||||
|
? "c.customer_last_read_at"
|
||||||
|
: "c.agent_last_read_at";
|
||||||
|
qb.andWhere(
|
||||||
|
`EXISTS (
|
||||||
|
SELECT 1 FROM freight.support_messages m
|
||||||
|
WHERE m.conversation_id = c.id
|
||||||
|
AND m.deleted_at IS NULL
|
||||||
|
AND m.author_role = :otherRole
|
||||||
|
AND (${cursorCol} IS NULL OR m.created_at > ${cursorCol})
|
||||||
|
)`,
|
||||||
|
{ otherRole },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return qb.getManyAndCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||||
|
import { SupportMessage } from "./entities/support-message.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupportMessageRepository extends BaseRepository<SupportMessage> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SupportMessage)
|
||||||
|
repo: Repository<SupportMessage>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All messages of a conversation, oldest first. */
|
||||||
|
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { conversationId },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unread message counts per conversation *for one side*: messages authored by
|
||||||
|
* the other role that are newer than the side's read cursor. Returns a map of
|
||||||
|
* conversationId → count (conversations with 0 unread are absent).
|
||||||
|
*/
|
||||||
|
async unreadCountsByConversation(
|
||||||
|
conversationIds: string[],
|
||||||
|
mySide: SupportAuthorRole,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
if (conversationIds.length === 0) return new Map();
|
||||||
|
const rows = await this.baseUnreadQuery(mySide)
|
||||||
|
.select("m.conversation_id", "conversationId")
|
||||||
|
.addSelect("COUNT(*)", "count")
|
||||||
|
.andWhere("m.conversation_id IN (:...ids)", { ids: conversationIds })
|
||||||
|
.groupBy("m.conversation_id")
|
||||||
|
.getRawMany<{ conversationId: string; count: string }>();
|
||||||
|
return new Map(rows.map((r) => [r.conversationId, Number(r.count)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of distinct conversations with at least one unread message for the side. */
|
||||||
|
async countUnreadConversations(
|
||||||
|
mySide: SupportAuthorRole,
|
||||||
|
companyId?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const qb = this.baseUnreadQuery(mySide).select(
|
||||||
|
"COUNT(DISTINCT m.conversation_id)",
|
||||||
|
"count",
|
||||||
|
);
|
||||||
|
if (companyId) {
|
||||||
|
qb.andWhere("c.company_id = :companyId", { companyId });
|
||||||
|
}
|
||||||
|
const row = await qb.getRawOne<{ count: string }>();
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base query for "unread for `mySide`": join the conversation, keep only
|
||||||
|
* messages from the opposite role that are newer than the side's read cursor.
|
||||||
|
*/
|
||||||
|
private baseUnreadQuery(mySide: SupportAuthorRole) {
|
||||||
|
const otherRole =
|
||||||
|
mySide === SupportAuthorRole.CUSTOMER
|
||||||
|
? SupportAuthorRole.AGENT
|
||||||
|
: SupportAuthorRole.CUSTOMER;
|
||||||
|
const cursorCol =
|
||||||
|
mySide === SupportAuthorRole.CUSTOMER
|
||||||
|
? "c.customer_last_read_at"
|
||||||
|
: "c.agent_last_read_at";
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder("m")
|
||||||
|
.innerJoin(SupportConversation, "c", "c.id = m.conversation_id")
|
||||||
|
.where("m.deleted_at IS NULL")
|
||||||
|
.andWhere("m.author_role = :otherRole", { otherRole })
|
||||||
|
.andWhere(`(${cursorCol} IS NULL OR m.created_at > ${cursorCol})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
freightType: 'CONTAINER',
|
freightType: 'CONTAINER',
|
||||||
cargoTotalWeightVgm: 210,
|
cargoTotalWeightVgm: 210,
|
||||||
bookingContainers: [
|
bookingContainers: [
|
||||||
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } },
|
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(service.wagonsFor(booking, dims)).toBe(3);
|
expect(service.wagonsFor(booking, dims)).toBe(3);
|
||||||
@@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
freightType: 'CONTAINER',
|
freightType: 'CONTAINER',
|
||||||
cargoTotalWeightVgm: 40,
|
cargoTotalWeightVgm: 40,
|
||||||
bookingContainers: [
|
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);
|
expect(service.wagonsFor(booking, dims)).toBe(2);
|
||||||
@@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
|||||||
{
|
{
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
wagonsRequired: 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,
|
wagonTypeDimensionsFromEntity,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
@@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||||
private readonly notifier: BookingNotifierService,
|
private readonly notifier: BookingNotifierService,
|
||||||
private readonly scheduler: SchedulerRegistry,
|
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 trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
@@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
? Math.ceil(booking.wagonsRequired)
|
? Math.ceil(booking.wagonsRequired)
|
||||||
: 0;
|
: 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.
|
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
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
|
* Keep schedule.max_wagons aligned with the train's boarding limit. A built
|
||||||
* locomotive's length-derived slot count. The physical wagons currently in
|
* train's limit is its physical consist — the wagon count staff marshalled
|
||||||
* the train set do NOT cap this — bookings are admitted on length/weight
|
* (and may change via adjust-consist). Only schedules WITHOUT a built train
|
||||||
* alone and yard staff attach the wagons manually before departure.
|
* 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(
|
private async syncScheduleMaxWagons(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
locomotive: Locomotive,
|
locomotive: Locomotive,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||||
const maxWagons = limits.base.wagons;
|
const maxWagons =
|
||||||
|
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
|
||||||
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
@@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||||
*
|
*
|
||||||
* The wagon axis is the locomotive's length-derived slot count only — the
|
* Two capacity regimes, decided by the schedule's train:
|
||||||
* physical wagons currently marshalled in the train set do NOT cap it.
|
* - Built train (Train Builder consist with physical wagons): the consist IS
|
||||||
* Bookings are admitted on length/weight capacity and yard staff attach
|
* the capacity. Wagon slots = physical wagon count; weight and length are
|
||||||
* the missing wagons manually before wagon assignment.
|
* 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(
|
private async remainingBudget(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
limits: TrainLimits,
|
limits: TrainLimits,
|
||||||
wagonDims: WagonDims,
|
wagonDims: WagonDims,
|
||||||
): Promise<CorridorBudget> {
|
): 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 stops = await this.stopsForSchedule(schedule);
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
@@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return budget;
|
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).
|
* 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
|
* ≤ 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 /
|
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||||
* train length for even one more loaded wagon. The old slot-only check let
|
* the capacity, weight/length were settled at build time.
|
||||||
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
|
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||||
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
|
* pull weight / train length for even one more loaded wagon. The old
|
||||||
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
|
* 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> {
|
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||||
const schedule =
|
const schedule =
|
||||||
@@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return this.isTrainFull(schedule);
|
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. */
|
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
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;
|
const locomotive = schedule.trainSet?.locomotive;
|
||||||
if (!locomotive) return false; // no weight/length limits to bind against
|
if (!locomotive) return false; // no weight/length limits to bind against
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -43,6 +45,8 @@ export class BookingJourneyService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly yardFacilities: YardFacilitiesService,
|
||||||
|
private readonly facilityHandling: FacilityHandlingService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -63,6 +67,7 @@ export class BookingJourneyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||||
|
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -72,6 +77,16 @@ export class BookingJourneyService {
|
|||||||
loadedByUserId: userId ?? null,
|
loadedByUserId: userId ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
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
|
// 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.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||||
|
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||||
|
|
||||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||||
// export continue into clearance, keyed on the booking's own arrival.
|
// export continue into clearance, keyed on the booking's own arrival.
|
||||||
@@ -112,6 +128,17 @@ export class BookingJourneyService {
|
|||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
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).
|
// 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,
|
* 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
|
* 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';
|
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateTrainSchedulingGlobalRulesDto {
|
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 })
|
@ApiPropertyOptional({ example: 53 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
maxWagonsPerTrain?: number;
|
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' })
|
@ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@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
|
// 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.
|
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||||
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,68 @@ export class IntercityService {
|
|||||||
* remaining capacity along all three axes (wagons, weight, length) and each
|
* remaining capacity along all three axes (wagons, weight, length) and each
|
||||||
* booking's need, so staff can pick what fits.
|
* booking's need, so staff can pick what fits.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Every intercity booking and where it is in its ride-along, across all trains.
|
||||||
|
*
|
||||||
|
* The per-schedule candidate list answers "what can THIS train carry"; this
|
||||||
|
* answers "what is happening to intercity cargo" — which is what a yard
|
||||||
|
* operator needs when the work is spread over whichever trains happen to pass.
|
||||||
|
*
|
||||||
|
* Carries each end's facility state, because a booking whose origin or
|
||||||
|
* destination has no facility can never be loaded or unloaded there and the
|
||||||
|
* operator should see that before the train arrives, not when the load is
|
||||||
|
* refused.
|
||||||
|
*/
|
||||||
|
async listBookings() {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT b.id AS "bookingId",
|
||||||
|
b.reference AS "reference",
|
||||||
|
b.status AS "status",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
|
b.cargo_total_weight_vgm AS "weightTons",
|
||||||
|
b.loaded_at AS "loadedAt",
|
||||||
|
b.arrived_at AS "arrivedAt",
|
||||||
|
company.name AS "customer",
|
||||||
|
b.train_schedule_id AS "trainScheduleId",
|
||||||
|
ts.train_number AS "trainNumber",
|
||||||
|
ts.status AS "scheduleStatus",
|
||||||
|
oy.id AS "originYardId",
|
||||||
|
COALESCE(oy.label, oy.code) AS "origin",
|
||||||
|
oy.has_facility AS "originHasFacility",
|
||||||
|
dy.id AS "destinationYardId",
|
||||||
|
COALESCE(dy.label, dy.code) AS "destination",
|
||||||
|
dy.has_facility AS "destinationHasFacility",
|
||||||
|
-- Where the train actually is, so the operator knows if the cargo
|
||||||
|
-- can be worked right now.
|
||||||
|
cp.yard_id AS "trainAtYardId",
|
||||||
|
-- Most recent GRN raised for this booking at a facility.
|
||||||
|
fh.grn_number AS "grnNumber"
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
|
LEFT JOIN freight.train_schedules ts
|
||||||
|
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT c.yard_id
|
||||||
|
FROM freight.train_checkpoint_events c
|
||||||
|
WHERE c.train_schedule_id = b.train_schedule_id
|
||||||
|
ORDER BY c.occurred_at DESC, c.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) cp ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT e.grn_number
|
||||||
|
FROM freight.facility_handling_events e
|
||||||
|
WHERE e.booking_id = b.id AND e.deleted_at IS NULL
|
||||||
|
ORDER BY e.occurred_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) fh ON true
|
||||||
|
WHERE b.deleted_at IS NULL
|
||||||
|
AND b.trade_direction = 'DOMESTIC'
|
||||||
|
ORDER BY b.created_at DESC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async listCandidates(scheduleId: string) {
|
async listCandidates(scheduleId: string) {
|
||||||
const schedule = await this.getSchedule(scheduleId);
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||||
|
|||||||
@@ -459,6 +459,16 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
return this.trainSchedulingService.dispatchSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("intercity/bookings")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Every intercity booking with its ride-along state, both yards' facility status, and where its train is",
|
||||||
|
})
|
||||||
|
listIntercityBookings() {
|
||||||
|
return this.intercityService.listBookings();
|
||||||
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/intercity-candidates")
|
@Get("schedules/:id/intercity-candidates")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module';
|
|||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.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 { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Route } from '../routes/entities/route.entity';
|
import { Route } from '../routes/entities/route.entity';
|
||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
@@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
|
FacilityHandlingEvent,
|
||||||
Locomotive,
|
Locomotive,
|
||||||
WagonType,
|
WagonType,
|
||||||
TrainSet,
|
TrainSet,
|
||||||
@@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
IntercityService,
|
IntercityService,
|
||||||
BookingJourneyService,
|
BookingJourneyService,
|
||||||
|
FacilityHandlingService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulingService,
|
TrainSchedulingService,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||||
import {
|
import {
|
||||||
DataSource,
|
DataSource,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -95,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
|||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import {
|
import {
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
summarizeFleetWarnings,
|
summarizeFleetWarnings,
|
||||||
@@ -317,6 +321,11 @@ export class TrainSchedulingService {
|
|||||||
private readonly bookingNotifier: BookingNotifierService,
|
private readonly bookingNotifier: BookingNotifierService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
|
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||||||
|
// existing specs that construct the service without it keep working.
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService?: BookingBatchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -591,7 +600,24 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrainSchedulingGlobalRules() {
|
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) {
|
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||||
@@ -599,15 +625,7 @@ export class TrainSchedulingService {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Train scheduling global rules not configured');
|
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.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.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||||||
@@ -645,7 +663,7 @@ export class TrainSchedulingService {
|
|||||||
await this.restampPendingWindows();
|
await this.restampPendingWindows();
|
||||||
}
|
}
|
||||||
|
|
||||||
return saved;
|
return this.toPublicGlobalRules(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2029,6 +2047,58 @@ export class TrainSchedulingService {
|
|||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
|
||||||
|
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
|
||||||
|
* loaded onto the wagons allocated to the booking, so anything still in the
|
||||||
|
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
|
||||||
|
* booking has warehouse inventory that never made it onto a wagon (received /
|
||||||
|
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
|
||||||
|
* or drop the booking's wagon allocation so it rides a later train.
|
||||||
|
*
|
||||||
|
* Import/domestic are untouched: their cargo isn't loaded out of an origin
|
||||||
|
* warehouse, so warehouse inventory says nothing about what's aboard.
|
||||||
|
*
|
||||||
|
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||||
|
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||||
|
*/
|
||||||
|
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||||
|
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
|
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
if (!route) return;
|
||||||
|
const direction = deriveTradeDirection(
|
||||||
|
{ country: route.originCountry },
|
||||||
|
{ country: route.destinationCountry },
|
||||||
|
);
|
||||||
|
if (direction !== 'EXPORT') return;
|
||||||
|
|
||||||
|
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||||
|
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||||
|
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||||
|
FROM sched_bookings sb
|
||||||
|
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||||
|
JOIN freight.warehouse_inventory inv
|
||||||
|
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
|
WHERE sb.schedule_id = $1
|
||||||
|
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||||
|
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async dispatchSchedule(scheduleId: string) {
|
async dispatchSchedule(scheduleId: string) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
@@ -2038,6 +2108,8 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||||
}
|
}
|
||||||
await this.assertImportDjiboutiMayDepart(schedule);
|
await this.assertImportDjiboutiMayDepart(schedule);
|
||||||
|
// Export only: don't leave received cargo behind in the warehouse.
|
||||||
|
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
@@ -5054,6 +5126,11 @@ export class TrainSchedulingService {
|
|||||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
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) => ({
|
const mapWagon = (wagon: Wagon) => ({
|
||||||
id: wagon.id,
|
id: wagon.id,
|
||||||
wagonNumber: wagon.wagonNumber,
|
wagonNumber: wagon.wagonNumber,
|
||||||
@@ -5092,6 +5169,12 @@ export class TrainSchedulingService {
|
|||||||
grossTons: roundTons(cargoTons + consistTareTons),
|
grossTons: roundTons(cargoTons + consistTareTons),
|
||||||
consistLengthMeters,
|
consistLengthMeters,
|
||||||
},
|
},
|
||||||
|
scheduleCapacity: wagonUsage
|
||||||
|
? {
|
||||||
|
...wagonUsage,
|
||||||
|
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
wagons: wagons.map((wagon) => ({
|
wagons: wagons.map((wagon) => ({
|
||||||
...mapWagon(wagon),
|
...mapWagon(wagon),
|
||||||
loaded: loadedWagonIds.has(wagon.id),
|
loaded: loadedWagonIds.has(wagon.id),
|
||||||
@@ -5284,7 +5367,37 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.getScheduleConsist(scheduleId);
|
// The consist IS the schedule's booking capacity, so an edit moves the
|
||||||
|
// FULL line: freeing slots on a FULL schedule reopens its window, taking
|
||||||
|
// the last slot closes it. Staff may shrink below what is already
|
||||||
|
// committed — allowed, but reported back as a warning (never silently).
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||||||
|
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
|
||||||
|
if (usage) {
|
||||||
|
const nowFull = usage.remainingSlots <= 0;
|
||||||
|
if (usage.overAllocatedBy > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
|
||||||
|
`${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
|
||||||
|
'Couple more wagons or free bookings before departure.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wasFull && !nowFull) {
|
||||||
|
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||||
|
warnings.push(
|
||||||
|
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
|
||||||
|
'so it is no longer FULL and can take bookings again.',
|
||||||
|
);
|
||||||
|
} else if (!wasFull && nowFull) {
|
||||||
|
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
|
||||||
|
warnings.push(
|
||||||
|
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => {
|
|||||||
fortyFooter.bookingContainers![0]!.containerType = {
|
fortyFooter.bookingContainers![0]!.containerType = {
|
||||||
code: '40GP',
|
code: '40GP',
|
||||||
sizeFt: 40,
|
sizeFt: 40,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
} as never;
|
} as never;
|
||||||
const result = planWagonsWithStock({
|
const result = planWagonsWithStock({
|
||||||
bookings: [fortyFooter],
|
bookings: [fortyFooter],
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe('wagon-plan.util', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
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 }]);
|
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||||
expect(sumWagonsRequired(booking)).toBe(3);
|
expect(sumWagonsRequired(booking)).toBe(3);
|
||||||
const plan = buildContainerWagonPlan([booking], nw5);
|
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) => ({
|
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
|
||||||
quantity,
|
quantity,
|
||||||
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
||||||
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||||
});
|
});
|
||||||
|
|
||||||
it('20×20ft = 10 wagons (not 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);
|
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.
|
// No containerType relation loaded → use the stored (0.5-aware) fraction.
|
||||||
expect(
|
expect(
|
||||||
containerWagonsForLines([
|
containerWagonsForLines([
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AllocationLoadType } from '@edr/types';
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { consistViolations } from './train-capacity.util';
|
import { consistViolations } from './train-capacity.util';
|
||||||
|
|
||||||
@@ -61,7 +62,6 @@ export type ContainerUnitRow = {
|
|||||||
label: string;
|
label: string;
|
||||||
grossWeightTons: number;
|
grossWeightTons: number;
|
||||||
sizeFt?: number;
|
sizeFt?: number;
|
||||||
wagonsPerUnit?: number;
|
|
||||||
containersPerWagon?: number;
|
containersPerWagon?: number;
|
||||||
teuSlots?: number;
|
teuSlots?: number;
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
@@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number {
|
|||||||
return sizeFt >= 40 ? 2 : 1;
|
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 = {
|
type ContainerLine = {
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
wagonsRequired?: 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
|
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
|
||||||
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
|
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
|
||||||
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
|
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
|
||||||
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
|
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
|
||||||
|
* wagons, not 4).
|
||||||
*/
|
*/
|
||||||
function lineWagonsRaw(line: ContainerLine): number {
|
function lineWagonsRaw(line: ContainerLine): number {
|
||||||
const qty = Number(line.quantity ?? 0);
|
const qty = Number(line.quantity ?? 0);
|
||||||
if (qty <= 0) return 0;
|
if (qty <= 0) return 0;
|
||||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
const sizeFt = Number(line.containerType?.sizeFt);
|
||||||
if (Number.isFinite(wpu) && wpu > 0) {
|
if (Number.isFinite(sizeFt) && sizeFt > 0) {
|
||||||
return qty * wpu;
|
return qty * wagonsPerUnitForSize(sizeFt);
|
||||||
}
|
}
|
||||||
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
|
// No size on the type: fall back to the line's stored fraction, else treat
|
||||||
// treat the whole line as one wagon.
|
// the whole line as one wagon.
|
||||||
const stored = Number(line.wagonsRequired);
|
const stored = Number(line.wagonsRequired);
|
||||||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
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 qty = Number(line.quantity ?? 0);
|
||||||
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||||||
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||||||
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
|
const perWagon = containersPerWagonForSize(sizeFt);
|
||||||
const perWagon = containersPerWagonFromType(wagonsPerUnit);
|
|
||||||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||||
// The REAL per-container numbers/weights entered at booking time. Unit i of
|
// 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
|
// 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}`,
|
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||||||
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
||||||
sizeFt,
|
sizeFt,
|
||||||
wagonsPerUnit,
|
|
||||||
containersPerWagon: perWagon,
|
containersPerWagon: perWagon,
|
||||||
teuSlots,
|
teuSlots,
|
||||||
containerNumber:
|
containerNumber:
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export class CreateWagonDto {
|
|||||||
// Tare weight and payload capacity are not accepted here: they belong to the
|
// Tare weight and payload capacity are not accepted here: they belong to the
|
||||||
// wagon type and are resolved through wagonTypeId.
|
// 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()
|
@IsOptional()
|
||||||
@IsEnum(WagonStatus)
|
@IsEnum(WagonStatus)
|
||||||
status?: WagonStatus;
|
status?: WagonStatus;
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export class ListWagonsQueryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trainNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 'wagonNumber' })
|
@ApiPropertyOptional({ default: 'wagonNumber' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export class Wagon extends BaseEntity {
|
|||||||
// Tare weight and payload capacity are properties of the wagon TYPE — read them
|
// Tare weight and payload capacity are properties of the wagon TYPE — read them
|
||||||
// through `wagonType`, never off the individual wagon.
|
// 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 })
|
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||||
status!: WagonStatusType;
|
status!: WagonStatusType;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
|
import { Repository, DataSource, In } from 'typeorm';
|
||||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||||
@@ -38,26 +38,47 @@ export class WagonsService {
|
|||||||
if (dto.trainId === undefined) wagon.trainId = null;
|
if (dto.trainId === undefined) wagon.trainId = null;
|
||||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||||
if (dto.currentYardId === undefined) wagon.currentYardId = 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);
|
return this.wagonRepo.save(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
|
||||||
const search = query.search?.trim();
|
const search = query.search?.trim();
|
||||||
const trainId = query.trainId?.trim();
|
const trainId = query.trainId?.trim();
|
||||||
const wagonTypeId = query.wagonTypeId?.trim();
|
const wagonTypeId = query.wagonTypeId?.trim();
|
||||||
const filters: FindOptionsWhere<Wagon> = {
|
const trainNumber = query.trainNumber?.trim();
|
||||||
...(query.status ? { status: query.status } : {}),
|
|
||||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
|
||||||
...(trainId ? { trainId } : {}),
|
|
||||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// QueryBuilder (not find) because both search and the trainNumber filter span
|
||||||
|
// two columns each (export/import run) — an OR that FindOptions cannot express
|
||||||
|
// without cross-producting into conflicting branches. Soft-deleted rows are
|
||||||
|
// still excluded automatically (BaseEntity's @DeleteDateColumn).
|
||||||
|
const qb = this.wagonRepo
|
||||||
|
.createQueryBuilder('w')
|
||||||
|
.leftJoinAndSelect('w.currentYard', 'currentYard')
|
||||||
|
.leftJoinAndSelect('w.wagonType', 'wagonType');
|
||||||
|
|
||||||
|
if (query.status) qb.andWhere('w.status = :status', { status: query.status });
|
||||||
|
if (query.currentYardId)
|
||||||
|
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
|
||||||
|
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
|
||||||
|
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
|
||||||
|
|
||||||
|
// Filter by run: the odd export run identifies the pair, so match either
|
||||||
|
// column — a wagon carries export on one, import on the other.
|
||||||
|
if (trainNumber) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)',
|
||||||
|
{ trainNumber },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches the wagon number or either run number.
|
||||||
if (search) {
|
if (search) {
|
||||||
where.push({
|
qb.andWhere(
|
||||||
wagonNumber: ILike(`%${search}%`),
|
'(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)',
|
||||||
...filters,
|
{ search: `%${search}%` },
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec columns (tare, payload) are no longer sortable here — they live on the
|
// Spec columns (tare, payload) are no longer sortable here — they live on the
|
||||||
@@ -73,14 +94,14 @@ export class WagonsService {
|
|||||||
? (query.sortBy as keyof Wagon)
|
? (query.sortBy as keyof Wagon)
|
||||||
: 'wagonNumber';
|
: 'wagonNumber';
|
||||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
qb.orderBy(`w.${sortBy}`, sortOrder);
|
||||||
|
|
||||||
return this.wagonRepo.find({
|
if (query.page && query.limit) {
|
||||||
where: search ? where : filters,
|
qb.skip((Number(query.page) - 1) * Number(query.limit));
|
||||||
relations: { currentYard: true, wagonType: true },
|
}
|
||||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
if (query.limit) qb.take(Number(query.limit));
|
||||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
|
||||||
take: query.limit ? Number(query.limit) : undefined,
|
return qb.getMany();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Wagon> {
|
async findById(id: string): Promise<Wagon> {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||||
import { Company } from '../companies/entities/company.entity';
|
import { Company } from '../companies/entities/company.entity';
|
||||||
@@ -1031,6 +1033,8 @@ export class WarehouseInventoryService {
|
|||||||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
|
||||||
|
// onto a train without one. Import GRN handling is left untouched.
|
||||||
const saved = await this.inventoryRepository.create({
|
const saved = await this.inventoryRepository.create({
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
@@ -1040,6 +1044,9 @@ export class WarehouseInventoryService {
|
|||||||
weight: Number(booking.weight) || 0,
|
weight: Number(booking.weight) || 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: new Date(),
|
arrivedAt: new Date(),
|
||||||
|
...(booking.tradeDirection === 'EXPORT'
|
||||||
|
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
|
||||||
|
: {}),
|
||||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||||
});
|
});
|
||||||
result.processedCount += 1;
|
result.processedCount += 1;
|
||||||
@@ -1060,6 +1067,14 @@ export class WarehouseInventoryService {
|
|||||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||||
|
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||||||
|
// a train without one. Import GRN handling is left untouched.
|
||||||
|
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT trade_direction AS "tradeDirection"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||||||
|
|
||||||
let location: DefaultLocation | null =
|
let location: DefaultLocation | null =
|
||||||
dto.warehouseId && dto.yardId && dto.zoneId
|
dto.warehouseId && dto.yardId && dto.zoneId
|
||||||
@@ -1080,6 +1095,10 @@ export class WarehouseInventoryService {
|
|||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
|
// Export only, and keep an already-issued GRN rather than reissuing.
|
||||||
|
...(isExport && !existing[0].grnNumber
|
||||||
|
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||||
|
: {}),
|
||||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(existing[0].id);
|
return this.findById(existing[0].id);
|
||||||
@@ -1094,6 +1113,9 @@ export class WarehouseInventoryService {
|
|||||||
weight: 0,
|
weight: 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt,
|
arrivedAt,
|
||||||
|
...(isExport
|
||||||
|
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||||
|
: {}),
|
||||||
notes: dto.notes ?? 'Unloaded',
|
notes: dto.notes ?? 'Unloaded',
|
||||||
});
|
});
|
||||||
return this.findById(saved.id);
|
return this.findById(saved.id);
|
||||||
@@ -1542,11 +1564,19 @@ export class WarehouseInventoryService {
|
|||||||
// their already-allocated wagons. Reuses the single-item load() machinery.
|
// their already-allocated wagons. Reuses the single-item load() machinery.
|
||||||
|
|
||||||
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||||||
|
/**
|
||||||
|
* Export flow this queue serves: booked -> paid -> received at the warehouse
|
||||||
|
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
|
||||||
|
* booking. Which bookings ride a train comes from the shared CTE.
|
||||||
|
*/
|
||||||
|
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
|
||||||
|
|
||||||
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||||||
const rows: Array<
|
const rows: Array<
|
||||||
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||||
> = await this.dataSource.query(
|
> = await this.dataSource.query(
|
||||||
`SELECT ts.id AS "scheduleId",
|
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||||
|
SELECT ts.id AS "scheduleId",
|
||||||
ts.train_number AS "trainNumber",
|
ts.train_number AS "trainNumber",
|
||||||
oy.code AS "origin",
|
oy.code AS "origin",
|
||||||
dy.code AS "destination",
|
dy.code AS "destination",
|
||||||
@@ -1554,15 +1584,15 @@ export class WarehouseInventoryService {
|
|||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
ts.status AS "status",
|
ts.status AS "status",
|
||||||
ts.scheduled_departure_date AS "departureTime",
|
ts.scheduled_departure_date AS "departureTime",
|
||||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
(SELECT count(*) FROM sched_bookings sb
|
||||||
JOIN freight.warehouse_inventory inv
|
JOIN freight.warehouse_inventory inv
|
||||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
WHERE sb.schedule_id = ts.id
|
||||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
|
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
|
||||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
(SELECT count(*) FROM sched_bookings sb
|
||||||
JOIN freight.warehouse_inventory inv
|
JOIN freight.warehouse_inventory inv
|
||||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
WHERE sb.schedule_id = ts.id
|
||||||
AND inv.status = 'LOADED') AS "loadedCount"
|
AND inv.status = 'LOADED') AS "loadedCount"
|
||||||
FROM freight.train_schedules ts
|
FROM freight.train_schedules ts
|
||||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
@@ -1570,11 +1600,11 @@ export class WarehouseInventoryService {
|
|||||||
WHERE ts.deleted_at IS NULL
|
WHERE ts.deleted_at IS NULL
|
||||||
AND ts.status = ANY($1)
|
AND ts.status = ANY($1)
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM freight.train_schedule_bookings tsb2
|
SELECT 1 FROM sched_bookings sb2
|
||||||
JOIN freight.warehouse_inventory inv2
|
JOIN freight.warehouse_inventory inv2
|
||||||
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
|
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
|
||||||
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
|
WHERE sb2.schedule_id = ts.id
|
||||||
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||||
)
|
)
|
||||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||||
[['DRAFT', 'SCHEDULED']],
|
[['DRAFT', 'SCHEDULED']],
|
||||||
@@ -1599,22 +1629,28 @@ export class WarehouseInventoryService {
|
|||||||
*/
|
*/
|
||||||
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
||||||
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
||||||
`SELECT inv.id AS "id",
|
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||||
|
SELECT inv.id AS "id",
|
||||||
inv.booking_id AS "bookingId",
|
inv.booking_id AS "bookingId",
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
company.name AS "customerName",
|
company.name AS "customerName",
|
||||||
ct.container_number AS "containerNumber",
|
ct.container_number AS "containerNumber",
|
||||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||||
inv.weight AS "weight",
|
inv.weight AS "weight",
|
||||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
|
-- receive() stamps the GRN onto the row and mirrors it into the
|
||||||
|
-- note; prefer the column and fall back for legacy/seeded rows.
|
||||||
|
COALESCE(
|
||||||
|
inv.grn_number,
|
||||||
|
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||||
|
) AS "grnNumber",
|
||||||
inv.inspection_status AS "inspectionStatus",
|
inv.inspection_status AS "inspectionStatus",
|
||||||
inv.status AS "status",
|
inv.status AS "status",
|
||||||
wl.wagon_id AS "wagonId",
|
wl.wagon_id AS "wagonId",
|
||||||
wl.wagon_number AS "wagonNumber",
|
wl.wagon_number AS "wagonNumber",
|
||||||
wl.sequence_no AS "sequenceNo"
|
wl.sequence_no AS "sequenceNo"
|
||||||
FROM freight.train_schedule_bookings tsb
|
FROM sched_bookings sb
|
||||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||||
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||||
@@ -1631,15 +1667,19 @@ export class WarehouseInventoryService {
|
|||||||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) wl ON true
|
) wl ON true
|
||||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
WHERE sb.schedule_id = $1
|
||||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||||
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
|
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
|
||||||
|
// The row only exists once the goods were received, so requiring a GRN and
|
||||||
|
// an allocated wagon completes the chain.
|
||||||
|
loadable:
|
||||||
|
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1692,6 +1732,9 @@ export class WarehouseInventoryService {
|
|||||||
if (!item) { skip('Not assigned to this train'); continue; }
|
if (!item) { skip('Not assigned to this train'); continue; }
|
||||||
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
||||||
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
||||||
|
// Export: the GRN is raised when the goods arrive at the warehouse, and
|
||||||
|
// nothing rides a train without one.
|
||||||
|
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
|
||||||
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -5031,10 +5074,9 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
return generateGrnNumber(direction, referenceId, date);
|
||||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
|
||||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||||
|
|||||||
39
apps/edr-freight-api/src/scripts/seed-edr-trucks.ts
Normal file
39
apps/edr-freight-api/src/scripts/seed-edr-trucks.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { AppDataSource } from '../data-source';
|
||||||
|
import { EdrTruckFleetSeeder } from '../seed/edr-truck-fleet.seeder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the 62-truck EDR fleet used by first-mile / last-mile.
|
||||||
|
*
|
||||||
|
* The seeder is idempotent (`ON CONFLICT (plate_number) DO NOTHING`), so a
|
||||||
|
* re-run will NOT overwrite a truck whose rate was tuned by hand.
|
||||||
|
*/
|
||||||
|
async function seedEdrTrucks() {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new EdrTruckFleetSeeder(AppDataSource).run();
|
||||||
|
|
||||||
|
const summary = await AppDataSource.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS trucks,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active,
|
||||||
|
COUNT(*) FILTER (WHERE availability = 'FREE')::int AS free,
|
||||||
|
COUNT(*) FILTER (WHERE price_per_km > 0)::int AS priced,
|
||||||
|
COUNT(*) FILTER (WHERE price_per_km IS NULL OR price_per_km <= 0)::int AS unpriced,
|
||||||
|
MIN(price_per_km)::text AS min_rate,
|
||||||
|
MAX(price_per_km)::text AS max_rate
|
||||||
|
FROM freight.vehicles
|
||||||
|
WHERE vehicle_type = 'TRUCK';
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.table(summary);
|
||||||
|
console.log('Seeded EDR truck fleet.');
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedEdrTrucks().catch((error) => {
|
||||||
|
console.error('Failed to seed EDR truck fleet:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { AppDataSource } from '../data-source';
|
import { AppDataSource } from '../data-source';
|
||||||
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||||
|
import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers';
|
||||||
|
import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers';
|
||||||
|
import { WagonNumberPartialUnique2280000000000 } from '../migrations/2280000000000-WagonNumberPartialUnique';
|
||||||
|
import { SeedWagonYardDoraleh2290000000000 } from '../migrations/2290000000000-SeedWagonYardDoraleh';
|
||||||
|
|
||||||
async function seedEdRWagons() {
|
async function seedEdRWagons() {
|
||||||
await AppDataSource.initialize();
|
await AppDataSource.initialize();
|
||||||
@@ -10,7 +14,18 @@ async function seedEdRWagons() {
|
|||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
// Fleet first (recreates every wagon with NULL yard + NULL runs), then the
|
||||||
|
// columns are ensured to exist, then the run roster and the yard are applied
|
||||||
|
// on top. Same order the migrations run in, so the script and a fresh
|
||||||
|
// migrate agree.
|
||||||
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||||
|
await new AddWagonTrainNumbers2270000000000().up(queryRunner);
|
||||||
|
// Not a wagon seed, but it owns wagon_number uniqueness — included so this
|
||||||
|
// script leaves the same schema a real `migration:run` would, rather than a
|
||||||
|
// database missing the partial unique index.
|
||||||
|
await new WagonNumberPartialUnique2280000000000().up(queryRunner);
|
||||||
|
await new SeedWagonRunNumbers2280000000000().up(queryRunner);
|
||||||
|
await new SeedWagonYardDoraleh2290000000000().up(queryRunner);
|
||||||
|
|
||||||
const summary = await queryRunner.query(`
|
const summary = await queryRunner.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -20,7 +35,8 @@ async function seedEdRWagons() {
|
|||||||
MIN(w.wagon_number) AS first_wagon,
|
MIN(w.wagon_number) AS first_wagon,
|
||||||
MAX(w.wagon_number) AS last_wagon,
|
MAX(w.wagon_number) AS last_wagon,
|
||||||
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||||
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS no_yard,
|
||||||
|
COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run
|
||||||
FROM freight.wagons w
|
FROM freight.wagons w
|
||||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
||||||
@@ -29,13 +45,45 @@ async function seedEdRWagons() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const [totals] = await queryRunner.query(`
|
const [totals] = await queryRunner.query(`
|
||||||
SELECT COUNT(*)::int AS total FROM freight.wagons;
|
SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run
|
||||||
|
FROM freight.wagons;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const yards = await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(y.label, '(no yard)') AS yard,
|
||||||
|
COUNT(*)::int AS wagons
|
||||||
|
FROM freight.wagons w
|
||||||
|
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
||||||
|
GROUP BY y.label
|
||||||
|
ORDER BY 2 DESC;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const runs = await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
export_train_number AS export_run,
|
||||||
|
import_train_number AS import_run,
|
||||||
|
COUNT(*)::int AS wagons
|
||||||
|
FROM freight.wagons
|
||||||
|
WHERE export_train_number IS NOT NULL
|
||||||
|
GROUP BY export_train_number, import_train_number
|
||||||
|
ORDER BY export_train_number;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
console.log('\nFleet by wagon type:');
|
||||||
console.table(summary);
|
console.table(summary);
|
||||||
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
console.log('Run roster (export/import pairs):');
|
||||||
|
console.table(runs);
|
||||||
|
console.log('Fleet by yard:');
|
||||||
|
console.table(yards);
|
||||||
|
console.log(
|
||||||
|
`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` +
|
||||||
|
`${totals.on_a_run} on a run (expected 533).`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await queryRunner.rollbackTransaction();
|
await queryRunner.rollbackTransaction();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ async function ensureReferences(manager: any) {
|
|||||||
code: '40FT',
|
code: '40FT',
|
||||||
label: '40FT',
|
label: '40FT',
|
||||||
sizeFt: 40,
|
sizeFt: 40,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ async function main() {
|
|||||||
code: '40FT',
|
code: '40FT',
|
||||||
label: '40FT',
|
label: '40FT',
|
||||||
sizeFt: 40,
|
sizeFt: 40,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
|
|||||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||||
import { CargoType } from '../modules/rule-engine/entities/cargo-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
@@ -115,7 +116,7 @@ async function main() {
|
|||||||
reeferQuantity: 0,
|
reeferQuantity: 0,
|
||||||
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
|
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
|
||||||
totalVgmTons: Number((weightKg / 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,
|
isOverweight: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '../modules/companies/entities/company.entity';
|
} from '../modules/companies/entities/company.entity';
|
||||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||||
import { LastMile } from '../modules/last-mile/entities/last-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
@@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
|||||||
await manager.getRepository(ContainerType).upsert(
|
await manager.getRepository(ContainerType).upsert(
|
||||||
CONTAINER_TYPES.map((containerType, index) => ({
|
CONTAINER_TYPES.map((containerType, index) => ({
|
||||||
...containerType,
|
...containerType,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wagonsRequired =
|
const wagonsRequired =
|
||||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||||
|
|
||||||
await manager.getRepository(Booking).upsert(
|
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 { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||||
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
||||||
import { CargoType } from "../modules/rule-engine/entities/cargo-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 { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||||
import { Container } from "../modules/container-management/entities/container.entity";
|
import { Container } from "../modules/container-management/entities/container.entity";
|
||||||
import { Route } from "../modules/routes/entities/route.entity";
|
import { Route } from "../modules/routes/entities/route.entity";
|
||||||
@@ -300,7 +301,6 @@ export class DemoBookingsSeeder {
|
|||||||
await manager.getRepository(ContainerType).upsert(
|
await manager.getRepository(ContainerType).upsert(
|
||||||
CONTAINER_TYPES.map((containerType, index) => ({
|
CONTAINER_TYPES.map((containerType, index) => ({
|
||||||
...containerType,
|
...containerType,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -400,7 +400,7 @@ export class DemoBookingsSeeder {
|
|||||||
.getRepository(BookingContainer)
|
.getRepository(BookingContainer)
|
||||||
.delete({ bookingId: booking.id });
|
.delete({ bookingId: booking.id });
|
||||||
const wagonsRequired =
|
const wagonsRequired =
|
||||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||||
|
|
||||||
await manager.getRepository(BookingContainer).insert({
|
await manager.getRepository(BookingContainer).insert({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ const EDR_TRUCK_FLEET: ReadonlyArray<readonly [string, string]> = [
|
|||||||
/** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */
|
/** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */
|
||||||
const TWENTY_FT_SEQS = new Set<number>();
|
const TWENTY_FT_SEQS = new Set<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haulage rate for the EDR truck fleet, ETB per km.
|
||||||
|
*
|
||||||
|
* First/last-mile billing is `distance × pricePerKm` (see FirstMileService /
|
||||||
|
* LastMileService `setDistances`), and a truck with no rate is refused at
|
||||||
|
* assignment. Applies to the whole fleet — override per truck in the fleet UI
|
||||||
|
* where a specific truck differs.
|
||||||
|
*/
|
||||||
|
const TRUCK_PRICE_PER_KM_ETB = 20000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EdrTruckFleetSeeder {
|
export class EdrTruckFleetSeeder {
|
||||||
private readonly logger = new Logger(EdrTruckFleetSeeder.name);
|
private readonly logger = new Logger(EdrTruckFleetSeeder.name);
|
||||||
@@ -50,7 +60,7 @@ export class EdrTruckFleetSeeder {
|
|||||||
const columns = [
|
const columns = [
|
||||||
'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no',
|
'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no',
|
||||||
'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity',
|
'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity',
|
||||||
'status', 'ownership', 'currency', 'description',
|
'status', 'ownership', 'currency', 'price_per_km', 'description',
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => {
|
const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => {
|
||||||
@@ -71,6 +81,7 @@ export class EdrTruckFleetSeeder {
|
|||||||
'ACTIVE',
|
'ACTIVE',
|
||||||
'EDR',
|
'EDR',
|
||||||
'ETB',
|
'ETB',
|
||||||
|
TRUCK_PRICE_PER_KM_ETB,
|
||||||
`EDR-owned container truck configured for ${ft} containers.`,
|
`EDR-owned container truck configured for ${ft} containers.`,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
'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(
|
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||||
(slug) => {
|
(slug) => {
|
||||||
const resource = slugToResourceKey(slug);
|
const resource = slugToResourceKey(slug);
|
||||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
||||||
|
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
||||||
return [
|
return [
|
||||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${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`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||||
manage: (slug: RuleEngineResourceSlug) =>
|
manage: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||||
|
approve: (slug: RuleEngineApprovableSlug) =>
|
||||||
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
||||||
},
|
},
|
||||||
allocation: {
|
allocation: {
|
||||||
manage: 'edr_freight_app:allocation:manage',
|
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 { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||||
import { LastMile } from '../modules/last-mile/entities/last-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 { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
@@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder {
|
|||||||
await manager.getRepository(ContainerType).upsert(
|
await manager.getRepository(ContainerType).upsert(
|
||||||
CONTAINER_TYPES.map((containerType, index) => ({
|
CONTAINER_TYPES.map((containerType, index) => ({
|
||||||
...containerType,
|
...containerType,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder {
|
|||||||
|
|
||||||
const isImport = demoBooking.tradeDirection === 'IMPORT';
|
const isImport = demoBooking.tradeDirection === 'IMPORT';
|
||||||
const wagonsRequired =
|
const wagonsRequired =
|
||||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||||
|
|
||||||
await manager.getRepository(Booking).upsert(
|
await manager.getRepository(Booking).upsert(
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export class PricingDataSeeder {
|
|||||||
code: "20FT",
|
code: "20FT",
|
||||||
label: "20FT Standard",
|
label: "20FT Standard",
|
||||||
sizeFt: 20,
|
sizeFt: 20,
|
||||||
wagonsPerUnit: 0.5,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -125,7 +124,6 @@ export class PricingDataSeeder {
|
|||||||
code: "40FT",
|
code: "40FT",
|
||||||
label: "40FT Standard",
|
label: "40FT Standard",
|
||||||
sizeFt: 40,
|
sizeFt: 40,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: false,
|
isReefer: false,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -135,7 +133,6 @@ export class PricingDataSeeder {
|
|||||||
code: "20FT_REEFER",
|
code: "20FT_REEFER",
|
||||||
label: "20FT Reefer",
|
label: "20FT Reefer",
|
||||||
sizeFt: 20,
|
sizeFt: 20,
|
||||||
wagonsPerUnit: 0.5,
|
|
||||||
isReefer: true,
|
isReefer: true,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -145,7 +142,6 @@ export class PricingDataSeeder {
|
|||||||
code: "40FT_REEFER",
|
code: "40FT_REEFER",
|
||||||
label: "40FT Reefer",
|
label: "40FT Reefer",
|
||||||
sizeFt: 40,
|
sizeFt: 40,
|
||||||
wagonsPerUnit: 1,
|
|
||||||
isReefer: true,
|
isReefer: true,
|
||||||
isOpenTop: false,
|
isOpenTop: false,
|
||||||
isActive: true,
|
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(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
LifeBuoy,
|
||||||
|
TrainFront,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -121,6 +123,7 @@ import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPag
|
|||||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||||
|
import IntercityPage from "./pages/warehouses/IntercityPage";
|
||||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||||
@@ -131,6 +134,7 @@ import { HealthCheck } from "./features/health/HealthCheck";
|
|||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import { UserManagementRoutes } from "./user-management/route";
|
import { UserManagementRoutes } from "./user-management/route";
|
||||||
import SetPassword from "./shared/components/SetPassword";
|
import SetPassword from "./shared/components/SetPassword";
|
||||||
|
import SupportInboxPage from "./pages/support/SupportInboxPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -142,14 +146,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Staff",
|
label: "Customers",
|
||||||
href: "/user-management",
|
href: "/dashboard/customers",
|
||||||
icon: <Users />,
|
icon: <Building2 />,
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Bookings",
|
|
||||||
href: "/dashboard/booking-requests",
|
|
||||||
icon: <FileText />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contracts",
|
label: "Contracts",
|
||||||
@@ -157,6 +156,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <FileSignature />,
|
icon: <FileSignature />,
|
||||||
permission: FREIGHT_PERMS.contracts.view,
|
permission: FREIGHT_PERMS.contracts.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Bookings",
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
icon: <FileText />,
|
||||||
|
},
|
||||||
// Operations hub: clearance-document review for contracts WITHOUT
|
// Operations hub: clearance-document review for contracts WITHOUT
|
||||||
// customs clearing (contract-level for one-time, per-booking for general).
|
// customs clearing (contract-level for one-time, per-booking for general).
|
||||||
{
|
{
|
||||||
@@ -165,11 +169,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Customers",
|
|
||||||
href: "/dashboard/customers",
|
|
||||||
icon: <Building2 />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Payments",
|
label: "Payments",
|
||||||
href: "/dashboard/payments",
|
href: "/dashboard/payments",
|
||||||
@@ -182,187 +181,194 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Receipt />,
|
icon: <Receipt />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Support",
|
||||||
|
href: "/dashboard/support",
|
||||||
|
icon: <LifeBuoy />,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Operations",
|
// title: "Port & Terminal",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Clearance",
|
label: "Operations",
|
||||||
href: "/dashboard/contracts/clearance",
|
icon: <Settings />,
|
||||||
icon: <ShieldCheck />,
|
children: [
|
||||||
permission: [
|
{
|
||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
label: "Clearance",
|
||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
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",
|
label: "Fleet Management",
|
||||||
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 />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.firstMile.view,
|
children: [
|
||||||
},
|
{
|
||||||
{
|
label: "Fleet Dashboard",
|
||||||
label: "Last Mile",
|
href: "/dashboard/fleet-dashboard",
|
||||||
href: "/dashboard/operations/last-mile",
|
icon: <LayoutDashboard />,
|
||||||
icon: <Truck />,
|
permission: FREIGHT_PERMS.fleetDashboard.view,
|
||||||
permission: FREIGHT_PERMS.lastMile.view,
|
},
|
||||||
},
|
{
|
||||||
],
|
label: "Routes",
|
||||||
},
|
href: "/dashboard/routes",
|
||||||
{
|
icon: <Network />,
|
||||||
title: "Fleet Management",
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
items: [
|
},
|
||||||
{
|
{
|
||||||
label: "Fleet Dashboard",
|
label: "Locomotives",
|
||||||
href: "/dashboard/fleet-dashboard",
|
href: "/dashboard/locomotives",
|
||||||
icon: <LayoutDashboard />,
|
icon: <Train />,
|
||||||
permission: FREIGHT_PERMS.fleetDashboard.view,
|
permission: FREIGHT_PERMS.fleet.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Routes",
|
label: "Train Builder",
|
||||||
href: "/dashboard/routes",
|
href: "/dashboard/train-builder",
|
||||||
icon: <Network />,
|
icon: <Hammer />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
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",
|
// label: "Wagon types",
|
||||||
// href: "/dashboard/wagon-types",
|
// href: "/dashboard/wagon-types",
|
||||||
// icon: <Boxes />,
|
// icon: <Boxes />,
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
label: "Wagons",
|
label: "Wagons",
|
||||||
href: "/dashboard/wagons",
|
href: "/dashboard/wagons",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
permission: FREIGHT_PERMS.fleet.view,
|
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",
|
label: "Imports",
|
||||||
href: "/dashboard/import-warehouse",
|
href: "/dashboard/import-warehouse",
|
||||||
@@ -437,35 +443,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Warehouse Management",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
label: "Warehouse Dashboard",
|
label: "Intercity",
|
||||||
href: "/dashboard/warehouse-dashboard",
|
href: "/dashboard/intercity",
|
||||||
icon: <LayoutDashboard />,
|
icon: <TrainFront />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Intercity Cargo",
|
||||||
|
href: "/dashboard/intercity",
|
||||||
|
icon: <TrainFront />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Warehouses",
|
label: "Warehouse Management",
|
||||||
href: "/dashboard/warehouses",
|
|
||||||
icon: <Container />,
|
icon: <Container />,
|
||||||
},
|
children: [
|
||||||
{
|
{
|
||||||
label: "Allocation & Fees",
|
label: "Warehouse Dashboard",
|
||||||
href: "/dashboard/warehouse-rules",
|
href: "/dashboard/warehouse-dashboard",
|
||||||
icon: <SlidersHorizontal />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Fee Invoices",
|
label: "Warehouses",
|
||||||
href: "/dashboard/warehouse-fee-invoices",
|
href: "/dashboard/warehouses",
|
||||||
icon: <Wallet />,
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "File settings",
|
label: "File settings",
|
||||||
@@ -485,12 +505,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ScrollText />,
|
icon: <ScrollText />,
|
||||||
permission: FREIGHT_PERMS.admin,
|
permission: FREIGHT_PERMS.admin,
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Freight configuration",
|
|
||||||
mutedTitle: true,
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
label: "Configuration",
|
label: "Configuration",
|
||||||
href: "/dashboard/configuration",
|
href: "/dashboard/configuration",
|
||||||
@@ -513,6 +527,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <SlidersHorizontal />,
|
icon: <SlidersHorizontal />,
|
||||||
children: getCategorySidebarChildren("rules"),
|
children: getCategorySidebarChildren("rules"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
label: "Staff",
|
||||||
|
href: "/user-management",
|
||||||
|
icon: <Users />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -599,7 +619,10 @@ const findActiveSidebarLabel = (
|
|||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const path = pathname.toLowerCase();
|
const path = pathname.toLowerCase();
|
||||||
const candidates = flattenSidebarItems(sections)
|
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);
|
.sort((a, b) => b.href.length - a.href.length);
|
||||||
|
|
||||||
return candidates.find(
|
return candidates.find(
|
||||||
@@ -674,10 +697,7 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
<Route
|
<Route path="um/set-password" element={<SetPassword />} />
|
||||||
path="um/set-password"
|
|
||||||
element={<SetPassword />}
|
|
||||||
/>
|
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -713,6 +733,7 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="support" element={<SupportInboxPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -926,6 +947,7 @@ const App = () => {
|
|||||||
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
|
<Route path="intercity" element={<IntercityPage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common";
|
|||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
|
||||||
export interface ClearanceReviewSectionProps {
|
export interface ClearanceReviewSectionProps {
|
||||||
@@ -272,17 +275,17 @@ export function ClearanceReviewSection({
|
|||||||
<>
|
<>
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="View">
|
<Tooltip label="View">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{
|
||||||
@@ -298,10 +301,21 @@ export function ClearanceReviewSection({
|
|||||||
)}
|
)}
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(
|
||||||
|
doc.file!.id,
|
||||||
|
doc.file!.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{ display: "flex" }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -544,7 +558,7 @@ function DocReviewCard({
|
|||||||
{hasFile &&
|
{hasFile &&
|
||||||
isViewable({
|
isViewable({
|
||||||
name: doc.file!.name,
|
name: doc.file!.name,
|
||||||
url: fileViewUrl(doc.file!.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="Preview document">
|
<Tooltip label="Preview document">
|
||||||
<Button
|
<Button
|
||||||
@@ -553,10 +567,9 @@ function DocReviewCard({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onView({
|
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||||
name: doc.file!.name,
|
onView,
|
||||||
url: fileViewUrl(doc.file!.id),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<
|
const CATEGORY_LABELS: Record<
|
||||||
Freight.ClearanceWorkflowFileCategory,
|
Freight.ClearanceWorkflowFileCategory,
|
||||||
@@ -97,8 +97,7 @@ function WorkflowFileRow({
|
|||||||
const file = item.file;
|
const file = item.file;
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
const viewUrl = fileViewUrl(file.id);
|
const canPreview = isViewable({ name: file.name, url: "" });
|
||||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="sm">
|
<Paper withBorder radius="md" p="sm">
|
||||||
@@ -129,7 +128,9 @@ function WorkflowFileRow({
|
|||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(file.id, file.name).then(onView)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common";
|
|||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import {
|
||||||
|
downloadBookingFile,
|
||||||
|
fetchViewableFile,
|
||||||
|
} from "@/services/files.service";
|
||||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
|
||||||
@@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({
|
|||||||
<>
|
<>
|
||||||
{isViewable({
|
{isViewable({
|
||||||
name: doc.file.name,
|
name: doc.file.name,
|
||||||
url: fileViewUrl(doc.file.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="View">
|
<Tooltip label="View">
|
||||||
<Box
|
<Box
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
view({
|
void fetchViewableFile(
|
||||||
name: doc.file!.name,
|
doc.file!.id,
|
||||||
url: fileViewUrl(doc.file!.id),
|
doc.file!.name,
|
||||||
})
|
).then(view)
|
||||||
}
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{
|
style={{
|
||||||
@@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({
|
|||||||
)}
|
)}
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Box
|
<Box
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(
|
||||||
|
doc.file!.id,
|
||||||
|
doc.file!.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
c="edr-green"
|
c="edr-green"
|
||||||
style={{ display: "flex" }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={15} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -611,7 +625,7 @@ function DocReviewCard({
|
|||||||
{hasFile &&
|
{hasFile &&
|
||||||
isViewable({
|
isViewable({
|
||||||
name: doc.file!.name,
|
name: doc.file!.name,
|
||||||
url: fileViewUrl(doc.file!.id),
|
url: "",
|
||||||
}) && (
|
}) && (
|
||||||
<Tooltip label="Preview document">
|
<Tooltip label="Preview document">
|
||||||
<Button
|
<Button
|
||||||
@@ -620,10 +634,9 @@ function DocReviewCard({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onView({
|
void fetchViewableFile(doc.file!.id, doc.file!.name).then(
|
||||||
name: doc.file!.name,
|
onView,
|
||||||
url: fileViewUrl(doc.file!.id),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
@@ -633,8 +646,11 @@ function DocReviewCard({
|
|||||||
{hasFile && (
|
{hasFile && (
|
||||||
<Tooltip label="Download">
|
<Tooltip label="Download">
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="button"
|
||||||
href={fileViewUrl(doc.file!.id, true)}
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(doc.file!.id, doc.file!.name)
|
||||||
|
}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: Number(l.quantity || 0),
|
quantity: Number(l.quantity || 0),
|
||||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||||
|
returnQuantity: Number(l.returnQuantity || 0),
|
||||||
})),
|
})),
|
||||||
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
|
||||||
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
|
||||||
@@ -532,6 +533,7 @@ export default function GlCreateBookingForm() {
|
|||||||
allowedSizes: containerSizes,
|
allowedSizes: containerSizes,
|
||||||
includeHazardous: contract?.isHazardous ?? false,
|
includeHazardous: contract?.isHazardous ?? false,
|
||||||
includeReefer: contract?.isReefer ?? false,
|
includeReefer: contract?.isReefer ?? false,
|
||||||
|
includeReturn: contractWithReturn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportFile = async (file: File | null) => {
|
const handleImportFile = async (file: File | null) => {
|
||||||
@@ -557,8 +559,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: String(imported.length),
|
quantity: String(imported.length),
|
||||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||||
returnQuantity:
|
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||||
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
|
||||||
units: imported.map((r) => ({
|
units: imported.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
sealNumber: r.sealNumber,
|
sealNumber: r.sealNumber,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@man
|
|||||||
import { Download, Eye, FileText } from "lucide-react";
|
import { Download, Eye, FileText } from "lucide-react";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
|
||||||
export interface PhasedUploadedFileRowProps {
|
export interface PhasedUploadedFileRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,8 +20,7 @@ export function PhasedUploadedFileRow({
|
|||||||
onDownload,
|
onDownload,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: PhasedUploadedFileRowProps) {
|
}: PhasedUploadedFileRowProps) {
|
||||||
const viewUrl = fileViewUrl(file.id);
|
const canPreview = isViewable({ name: file.name, url: "" });
|
||||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -61,7 +60,9 @@ export function PhasedUploadedFileRow({
|
|||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Eye size={13} />}
|
leftSection={<Eye size={13} />}
|
||||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(file.id, file.name).then(onView)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user