From bd81bbfc2c4baff04a81194c652189b219f80099 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 17 Jul 2026 11:15:58 +0300 Subject: [PATCH 01/16] style: sidebar improvmetn --- apps/edr-freight-web/backoffice/src/App.tsx | 432 +++++++++--------- .../src/components/layout/FreightSidebar.tsx | 34 +- .../backoffice/src/components/layout/types.ts | 2 +- 3 files changed, 237 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..81a8552e7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -18,6 +18,7 @@ import { Send, Settings, ShieldCheck, + Settings2, Ship, SlidersHorizontal, Train, @@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "Staff", - href: "/user-management", - icon: , - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , + label: "Customers", + href: "/dashboard/customers", + icon: , }, { label: "Contracts", @@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). { @@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.opsClearanceReview, }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, { label: "Payments", href: "/dashboard/payments", @@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Operations", + // title: "Port & Terminal", items: [ { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, ], }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", + label: "Fleet Management", icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Port & Terminal", - items: [ { label: "Imports", href: "/dashboard/import-warehouse", @@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - ], - }, - { - title: "Warehouse Management", - items: [ { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", + label: "Warehouse Management", icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], }, ], }, { - title: "Administration", + title: "Freight configuration", + mutedTitle: true, items: [ { label: "File settings", @@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ { label: "Configuration", href: "/dashboard/configuration", @@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: getCategorySidebarChildren("rules"), }, + + { + label: "Staff", + href: "/user-management", + icon: , + }, ], }, ]; @@ -599,7 +599,10 @@ const findActiveSidebarLabel = ( ): string | undefined => { const path = pathname.toLowerCase(); const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) .sort((a, b) => b.href.length - a.href.length); return candidates.find( @@ -674,10 +677,7 @@ const App = () => { } /> {/* } /> */} - } - /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 698b0fc2c..91429356c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -92,7 +92,9 @@ const FreightSidebar = ({ walk(item.children, key); }); }; - sections.forEach((section) => walk(section.items, section.title)); + sections.forEach((section, i) => + walk(section.items, section?.title ?? "" + i++), + ); return acc; }, [sections, isHrefActive, branchActive]); @@ -126,6 +128,7 @@ const FreightSidebar = ({ opened={isOpen} classNames={navClassNames(active)} onClick={() => toggle(key)} + childrenOffset="sm" rightSection={ @@ -166,6 +169,7 @@ const FreightSidebar = ({ active={active} component={Link} classNames={navClassNames(active)} + onClick={onClose} to={item.href!} /> ); @@ -177,19 +181,21 @@ const FreightSidebar = ({ () => sections.map((section) => ( - - {section.title} - + {section.title && ( + + {section.title} + + )} {section.items.map((item, i) => - renderItem(item, itemKey(section.title, item, i)), + renderItem(item, itemKey(section.title ?? "" + i, item, i)), )} @@ -257,7 +263,7 @@ const FreightSidebar = ({ px="sm" pb="md" > - {renderedSections} + {renderedSections} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts index 051f28839..2129e05e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -12,7 +12,7 @@ export interface SidebarItem { export interface SidebarSection { /** Section label shown above a group of nav items (e.g. "Main menu"). */ - title: string; + title?: string; items: SidebarItem[]; /** When true, section title uses muted grey instead of dark text. */ mutedTitle?: boolean; From b4118e1bd42c989107df56a5fd0f7a2af152240d Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 08:47:52 +0000 Subject: [PATCH 02/16] add export/inport number --- .../2270000000000-AddWagonTrainNumbers.ts | 28 +++++++++++++++++++ .../modules/wagons/dto/create-wagon.dto.ts | 10 +++++++ .../modules/wagons/entities/wagon.entity.ts | 8 ++++++ .../src/modules/wagons/wagons.service.ts | 2 ++ .../src/pages/fleet/config/resources.ts | 18 ++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index d1939c9f5..408b13be5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -20,6 +20,16 @@ export class CreateWagonDto { // Tare weight and payload capacity are not accepted here: they belong to the // wagon type and are resolved through wagonTypeId. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */ + @IsOptional() + @IsString() + exportTrainNumber?: string; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */ + @IsOptional() + @IsString() + importTrainNumber?: string; + @IsOptional() @IsEnum(WagonStatus) status?: WagonStatus; diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index b66fc3f9d..7bfb59e1d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -43,6 +43,14 @@ export class Wagon extends BaseEntity { // Tare weight and payload capacity are properties of the wagon TYPE — read them // through `wagonType`, never off the individual wagon. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 8c5dd83c8..a43ff0e67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -38,6 +38,8 @@ export class WagonsService { if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; + if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; + if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 82c1eaa58..b5bc5d415 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -271,6 +271,22 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ + // Run numbers are optional — a wagon sits in the fleet unassigned to any + // run until an operator fills these in. + { + name: "exportTrainNumber", + label: "Export train number", + type: "text", + description: "Odd — Ethiopia → Djibouti runs", + placeholder: "e.g. 8001", + }, + { + name: "importTrainNumber", + label: "Import train number", + type: "text", + description: "Even — Djibouti → Ethiopia runs", + placeholder: "e.g. 8002", + }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, @@ -278,6 +294,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { + exportTrainNumber: "", + importTrainNumber: "", wagonNumber: "", wagonTypeId: "", currentYardId: "", From ef74bc442ab60ee779d0ad9b9f3f05ecee7e46c3 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 17 Jul 2026 08:54:24 +0000 Subject: [PATCH 03/16] fix: prevent the backoffice from approve the user before he submits --- .../modules/companies/companies.repository.ts | 74 ++++++++++++++++--- .../modules/companies/companies.service.ts | 25 +++++++ .../dto/company-stats-response.dto.ts | 3 + .../companies/dto/list-companies-query.dto.ts | 12 ++- .../companies/dto/response-company.dto.ts | 11 +++ .../src/components/customers/badges.tsx | 19 +++++ .../pages/customers/CustomerDetailPage.tsx | 50 +++++++++++-- .../src/pages/customers/CustomersPage.tsx | 49 ++++++++++-- .../backoffice/src/types/customer.ts | 33 +++++++++ 9 files changed, 253 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 15ca85c73..6a0365854 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { + /** + * A company still being filled in by its owner in the portal wizard: it was + * self-registered (so it has an external profile) and nobody has submitted + * onboarding yet. The row exists from the wizard's first click, carrying a + * placeholder name + TIN, so it must not be offered up for review. + * Staff-created companies have no external profiles and are never drafts. + */ + private static readonly DRAFT_SQL = `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = company.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + constructor( @InjectRepository(Company) repo: Repository, @@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, kind, status } = query; + const { + page = 1, + pageSize = 20, + search, + type, + kind, + status, + onboardingCompleted, + } = query; const qb = this.repository .createQueryBuilder('company') .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + // External profiles carry onboardingCompleted, which the backoffice list + // uses to flag customers still mid-onboarding (not yet reviewable). + .leftJoinAndSelect('company.profiles', 'profiles') .where('company.deleted_at IS NULL'); if (type) { @@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (onboardingCompleted !== undefined) { + qb.andWhere( + onboardingCompleted + ? `NOT ${CompaniesRepository.DRAFT_SQL}` + : CompaniesRepository.DRAFT_SQL, + ); + } + if (search) { const term = `%${search.trim()}%`; qb.andWhere( @@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository { } async getStats(): Promise { - const rows: { status: string; count: string }[] = await this.repository - .createQueryBuilder('company') - .select('company.status', 'status') - .addSelect('COUNT(*)', 'count') - .where('company.deleted_at IS NULL') - .groupBy('company.status') - .getRawMany(); + // Drafts are counted separately rather than under `pending`: they carry + // status=pending from creation, which would otherwise inflate the review + // queue's KPI with customers who haven't submitted anything yet. + const rows: { status: string; is_draft: boolean; count: string }[] = + await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .addGroupBy(CompaniesRepository.DRAFT_SQL) + .getRawMany(); - const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + const map = new Map(); + let onboarding = 0; + let total = 0; + for (const row of rows) { + const count = parseInt(row.count, 10); + total += count; + if (row.is_draft) onboarding += count; + else map.set(row.status, (map.get(row.status) ?? 0) + count); + } return { total, active: map.get('active') ?? 0, pending: map.get('pending') ?? 0, + onboarding, suspended: map.get('suspended') ?? 0, blacklisted: map.get('blacklisted') ?? 0, }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1027de955..04b8790cd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -372,6 +372,9 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + // External profiles carry the onboarding flag the backoffice gates + // approval decisions on (see ResponseCompanyDto.onboardingCompleted). + company.profiles = await this.profilesRepo.findByCompanyId(id); return company; } @@ -962,6 +965,28 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // A self-registered company is only reviewable once its owner submits the + // onboarding wizard (markOnboardingComplete) — until then its profiles are + // half-filled drafts and approving one would mint a reference against an + // application that doesn't exist yet. Staff-created companies have no + // external profiles and are exempt. + // + // Only the review decision itself is gated (a profile still awaiting one: + // Pending, or Rejected and awaiting re-approval). Profiles already in + // service stay managable so staff can suspend/blacklist them — including to + // undo an approval granted before this guard existed. + const awaitingReview = + existing.status === ProfileStatus.Pending || + existing.status === ProfileStatus.Rejected; + if (awaitingReview) { + const owners = await this.profilesRepo.findByCompanyId(existing.companyId); + if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) { + throw new BadRequestException( + "This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.", + ); + } + } + // A reference number is only minted the first time a profile is approved // (status → Active). Pending/unapproved profiles carry no reference. const patch: Partial = { status }; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts index a6b8b3b6e..c054b3531 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -1,7 +1,10 @@ export class CompanyStatsResponseDto { total!: number; active!: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending!: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding!: number; suspended!: number; blacklisted!: number; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 4dbb932cb..adaa12479 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; @@ -37,4 +37,14 @@ export class ListCompaniesQueryDto { @IsOptional() @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + + @ApiPropertyOptional({ + description: + "Filter by onboarding submission. `true` = reviewable applications; " + + "`false` = drafts still in the portal wizard. Omit for both.", + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === "true" || value === true) + @IsBoolean() + onboardingCompleted?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 0c783cbcf..a05812558 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -62,6 +62,13 @@ export class ResponseCompanyDto { attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; + /** + * Whether the owning portal user has submitted the onboarding wizard. + * Approval decisions are blocked while this is false. Staff-created + * companies (no external profiles) count as completed. Undefined when the + * external profiles weren't loaded. + */ + onboardingCompleted?: boolean; createdAt: Date; updatedAt: Date; @@ -84,6 +91,10 @@ export class ResponseCompanyDto { this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), ); + this.onboardingCompleted = company.profiles + ? company.profiles.length === 0 || + company.profiles.some((p) => p.onboardingCompleted) + : undefined; this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 76555a014..6cb6759e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -280,13 +280,20 @@ export function InvoiceStatusBadge({ * Transitions: pending → approve / reject-with-note | rejected → approve (override) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. * Rejecting captures a note the customer sees so they can fix and reapply. + * + * `locked` (customer hasn't submitted onboarding) withholds the review decision + * only — there's no application to judge yet, and the API rejects the call + * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so + * an already-active profile is still managable. */ export function ProfileApprovalActions({ profileId, status, + locked = false, }: { profileId: string; status: ProfileStatus; + locked?: boolean; }) { const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), @@ -346,6 +353,18 @@ export function ProfileApprovalActions({ ); + // Pending/rejected are the two states awaiting a reviewer's decision — the + // exact pair the API gates on until the customer submits. + if (locked && (status === "pending" || status === "rejected")) { + return ( + + + Awaiting submission + + + ); + } + if (status === "pending") { return ( <> diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index dc682f1c7..9ae5a771b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Anchor, Badge, Box, @@ -22,6 +23,7 @@ import { Download, Eye, FileText, + Hourglass, IdCard, LayoutGrid, Package, @@ -60,6 +62,7 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; +import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer"; import type { Invoice } from "@/types/invoice"; import { DataTable, @@ -166,6 +169,13 @@ export default function CustomerDetailPage() { ); const paidCurrency = payments[0]?.currency ?? "ETB"; + // The company row is created on the wizard's first click, so a draft reaches + // this page with a placeholder name/TIN. `stillOnboarding` drives the banner + // and badge; `canReview` gates the approve/reject buttons and mirrors the + // API's rule exactly, so no button is offered that the server would reject. + const stillOnboarding = company ? isOnboardingDraft(company) : false; + const canReview = company ? hasSubmittedOnboarding(company) : true; + const profileColumns: ColumnDef[] = useMemo( () => [ { @@ -273,11 +283,12 @@ export default function CustomerDetailPage() { ), }, ], - [view], + [view, canReview], ); const bookingColumns: ColumnDef[] = useMemo( @@ -602,7 +613,13 @@ export default function CustomerDetailPage() { meta={ - + {stillOnboarding ? ( + + Onboarding in progress + + ) : ( + + )} } @@ -631,6 +648,21 @@ export default function CustomerDetailPage() { {/* OVERVIEW */} + {stillOnboarding && ( + } + title="This customer hasn't submitted their application yet" + > + They're still filling in the onboarding wizard, so the details + below are an unfinished draft — the company name and TIN are + placeholders until they reach those steps. Role profiles become + reviewable once the application is submitted. + + )} + p.status === "pending", - ).length, + // A draft's profiles are all `pending` by construction, which + // would read as a review backlog that doesn't exist yet. + label: stillOnboarding + ? "Awaiting submission" + : "Pending approval", + value: stillOnboarding + ? "—" + : company.companyProfiles.filter( + (p) => p.status === "pending", + ).length, icon: IdCard, color: "yellow", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index aff353055..89325ae79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -16,6 +16,7 @@ import { Building2, CheckCircle2, Clock, + Hourglass, Mail, Phone, RefreshCw, @@ -36,6 +37,7 @@ import { import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Company, CompanyStatus } from "@/types/customer"; +import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -43,22 +45,39 @@ import { type ColumnDef, } from "@edr/ui-common"; +/** + * The list's segmented views. "Pending approval" means submitted-and-awaiting- + * review, so it excludes drafts — a company row exists from the onboarding + * wizard's first click and would otherwise pad the review queue. Those drafts + * get their own view instead of disappearing, so staff can still chase them. + */ +type CustomerView = "all" | "pending" | "onboarding" | "active"; + +const VIEW_FILTERS: Record< + CustomerView, + { status?: CompanyStatus; onboardingCompleted?: boolean } +> = { + all: {}, + pending: { status: "pending", onboardingCompleted: true }, + onboarding: { onboardingCompleted: false }, + active: { status: "active" }, +}; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). - const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); + const [view, setView] = useState("all"); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, - status: statusFilter || undefined, + ...VIEW_FILTERS[view], }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -114,6 +133,17 @@ export default function CustomersPage() { id: "status", header: "Status", cell: ({ row }) => { + // A draft's profiles are all `pending` by construction, so the + // "N pending" review hint would be a lie until they submit. + if (isOnboardingDraft(row.original)) { + return ( + + + Onboarding + + + ); + } const pending = (row.original.companyProfiles ?? []).filter( (p) => p.status === "pending", ).length; @@ -206,6 +236,12 @@ export default function CustomersPage() { { label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" }, { label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" }, { label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" }, + { + label: "Onboarding", + value: stats?.onboarding ?? "—", + icon: Hourglass, + color: "gray", + }, { label: "Blacklisted", value: stats?.blacklisted ?? "—", @@ -243,14 +279,15 @@ export default function CustomersPage() { { - setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setView(v as CustomerView); setPagination((prev) => ({ ...prev, pageIndex: 0 })); }} data={[ { label: "All", value: "all" }, { label: "Pending approval", value: "pending" }, + { label: "Onboarding", value: "onboarding" }, { label: "Active", value: "active" }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8fb03cf86..ef88403a9 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -146,10 +146,38 @@ export interface Company { website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; + /** + * Whether the customer submitted their onboarding application. A company row + * is created on the wizard's first click, so a `pending` company with this + * false is a half-filled draft — not reviewable. Staff-created companies are + * always true. Undefined on endpoints that don't load external profiles. + */ + onboardingCompleted?: boolean; createdAt: string; updatedAt: string; } +/** + * Whether the customer has submitted their onboarding application. Mirrors the + * API's review gate (`setCompanyProfileStatus`): until this is true, a role + * awaiting a decision cannot be approved or rejected. Companies loaded without + * external profiles (`undefined`) are treated as submitted — absence of the + * flag must not lock staff out. + */ +export function hasSubmittedOnboarding(company: Company): boolean { + return company.onboardingCompleted !== false; +} + +/** + * A pristine draft: still `pending` and never submitted, so its name/TIN are + * placeholders and there is nothing to review. Drives presentation only — the + * approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted) + * case of a company activated before that gate existed. + */ +export function isOnboardingDraft(company: Company): boolean { + return company.status === "pending" && !hasSubmittedOnboarding(company); +} + /** Query parameters for the company list. */ export interface CompanyListFilter { page: number; @@ -158,6 +186,8 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + /** `true` = submitted applications only; `false` = drafts only; omit for both. */ + onboardingCompleted?: boolean; } /** Standard paginated list envelope (matches the bookings service shape). */ @@ -170,7 +200,10 @@ export interface PaginatedCompanies { export interface CompanyStats { total: number; active: number; + /** Submitted applications awaiting review. Excludes drafts. */ pending: number; + /** Self-registered companies still working through the onboarding wizard. */ + onboarding: number; suspended: number; blacklisted: number; } From 1abf71306ea3f05b5ed089fcb85887b0f6d931d2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 09:15:21 +0000 Subject: [PATCH 04/16] add export/inport number --- .../src/components/fleet/FleetFormDialog.tsx | 27 +++++++++++- .../src/pages/fleet/config/resources.ts | 41 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 7262e7927..dfb27ed36 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -271,8 +271,14 @@ const FleetFormDialog = ({ return; } if (!validate()) return; + // Derived fields are never edited, so form state for them can be stale (or + // seeded from the record) — recompute before building the payload. + const submitted: Record = { ...values }; + fields.forEach((field) => { + if (field.derivedValue) submitted[field.name] = field.derivedValue(values); + }); const payload = Object.fromEntries( - Object.entries(values) + Object.entries(submitted) .map(([key, value]) => { if (value === FLEET_SELECT_NONE || value === "") return [key, undefined]; @@ -294,6 +300,23 @@ const FleetFormDialog = ({ // only by verification and never hand-edited. const isDisabled = Boolean(field.disabled || field.faydaLocked); + // Computed from other fields (e.g. the import run implied by the export + // run) — read-only, and recomputed here rather than read from form state. + if (field.derivedValue) { + return ( + + ); + } + if (field.type === "radio") { return ( ) => string; /** * Field is owned by the Fayda identity — populated only by verification and * never hand-edited. Rendered disabled in the form. @@ -116,6 +123,32 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing + * an export number fully determines the import one. Listed explicitly rather + * than computed as export+1, so a pair that ever breaks that convention stays + * correct here. + */ +const TRAIN_RUN_PAIRS: Record = { + "8001": "8002", + "81001": "81002", + "82001": "82002", + "83001": "83002", + "84001": "84002", + "85001": "85002", + "86001": "86002", + "87001": "87002", + "88001": "88002", + "8901": "8902", + "9001": "9002", +}; + +const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({ + label: run, + value: run, +})); + export const FLEET_RESOURCES: FleetResourceConfig[] = [ @@ -272,13 +305,15 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ ], formFields: [ // Run numbers are optional — a wagon sits in the fleet unassigned to any - // run until an operator fills these in. + // run until an operator picks an export run. The import run is fixed by + // that choice, so it is derived rather than typed. { name: "exportTrainNumber", label: "Export train number", - type: "text", + type: "select", description: "Odd — Ethiopia → Djibouti runs", placeholder: "e.g. 8001", + options: EXPORT_TRAIN_OPTIONS, }, { name: "importTrainNumber", @@ -286,6 +321,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ type: "text", description: "Even — Djibouti → Ethiopia runs", placeholder: "e.g. 8002", + derivedValue: (values) => + TRAIN_RUN_PAIRS[String(values.exportTrainNumber ?? "")] ?? "", }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, From 7b7e7c3f620c0bc1d6cc966bd36e75ba7f368c0c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 09:16:35 +0000 Subject: [PATCH 05/16] fix schule issue and contianer type issue --- ...290000000000-DropContainerWagonsPerUnit.ts | 29 +++++ .../bookings/booking-pricing.service.ts | 8 +- .../booking-reference-data.service.ts | 1 - .../modules/bookings/bookings.repository.ts | 8 +- .../src/modules/bookings/bookings.service.ts | 3 +- .../modules/bookings/consolidation.service.ts | 10 +- .../dto/booking-reference-data.dto.ts | 3 - .../contracts/contract-booking.service.ts | 7 +- .../rule-engine/container-type.util.ts | 15 +++ .../priority-configs.controller.ts | 3 +- .../dto/create-container-type.dto.ts | 9 +- .../entities/container-type.entity.ts | 3 - .../services/container-types.service.ts | 1 - .../services/priority-configs.range.spec.ts | 46 +++---- .../services/priority-configs.service.ts | 41 ++---- .../booking-batch.service.spec.ts | 109 +++++++++++++++- .../train-scheduling/booking-batch.service.ts | 118 +++++++++++++++--- .../train-scheduling/fleet-plan.util.ts | 2 +- .../train-scheduling.service.ts | 51 +++++++- .../wagon-plan-flex.util.spec.ts | 1 - .../train-scheduling/wagon-plan.util.spec.ts | 6 +- .../train-scheduling/wagon-plan.util.ts | 33 ++--- .../scripts/seed-gate-pass-train-scenarios.ts | 1 - .../seed-negad-indode-arrived-train.ts | 1 - .../seed-warehouse-export-receive-ready.ts | 3 +- ...ved-first-lastmile-demo-bookings.seeder.ts | 4 +- .../src/seed/demo-bookings.seeder.ts | 4 +- .../paid-import-export-mile-demo.seeder.ts | 4 +- .../src/seed/pricing-data.seeder.ts | 4 - apps/edr-freight-web/backoffice/src/App.tsx | 24 ++-- .../trainScheduling/AdjustConsistModal.tsx | 90 ++++++++++++- .../containerPlacement.util.spec.ts | 2 - .../src/pages/bookings/NewBookingPage.tsx | 1 - .../src/pages/ruleEngine/config/resources.ts | 2 +- .../src/pages/ruleEngine/priorityRuleRange.ts | 26 ++-- .../backoffice/src/services/api.ts | 3 +- .../src/services/trainBuilder.service.ts | 17 ++- .../backoffice/src/types/trainScheduling.ts | 1 - packages/types/src/freight/index.ts | 1 - 39 files changed, 508 insertions(+), 187 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts diff --git a/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts new file mode 100644 index 000000000..ed66c37d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-DropContainerWagonsPerUnit.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * container_types.wagons_per_unit is no longer stored: the wagon fraction is + * derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per + * wagon; see rule-engine/container-type.util.ts). The stored value duplicated + * that rule and could silently drift from it. + */ +export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface { + name = 'DropContainerWagonsPerUnit2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2); + `); + // Backfill from the same size rule the code now derives from. + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 091232611..9a1f127e8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -10,11 +10,9 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; -import { - containersPerWagon, - wagonRemainder, -} from './consolidation.service'; +import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -308,7 +306,7 @@ export class BookingPricingService { totalVgmTons: qty * vgm, isReefer: ct.isReefer, }, - perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), + perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, }; }), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 6e96dc6f8..a978fd22b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -110,7 +110,6 @@ export function groupContainersBySize( name: ct.label?.trim() ? ct.label : ct.code, code: ct.code, is_reefer: ct.isReefer ?? false, - wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), }), ), })); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 72211925f..aeae7d10a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; @@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository { for (const item of containers) { const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt); const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); // A per-line breakdown can never exceed the line's own quantity. @@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository { async calculateWagonCount(bookingId: string): Promise { const result = await this.dataSource .createQueryBuilder() - .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .select( + 'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))', + 'total', + ) .from(BookingContainer, 'bc') .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') .where('bc.booking_id = :bookingId', { bookingId }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 588e4f1ed..d9dd53f94 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, @@ -438,7 +439,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, - wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index e16b97997..9a87054e2 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { containersPerWagonForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { Booking } from './entities/booking.entity'; @@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult { messages: string[]; } -/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ -export function containersPerWagon(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - export function wagonRemainder(quantity: number, perWagon: number): number { const r = quantity % perWagon; return r; @@ -73,7 +67,7 @@ export class ConsolidationService { const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); - const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const perWagon = containersPerWagonForSize(ct.sizeFt); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index c930d7aa1..a793cc558 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto { @ApiProperty() is_reefer!: boolean; - - @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) - wagons_per_unit!: number; } export class BookingReferenceContainerSizeGroupDto { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a0c2b42a4..826bc33b0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -1053,7 +1054,7 @@ export class ContractBookingService { bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; - bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, @@ -1513,7 +1514,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, - wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), @@ -1651,7 +1652,7 @@ export class ContractBookingService { : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, - wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; diff --git a/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts new file mode 100644 index 000000000..ed64c1edb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/container-type.util.ts @@ -0,0 +1,15 @@ +/** + * Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon, + * 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts + * never under-book. + */ +export function wagonsPerUnitForSize(sizeFt?: number | null): number { + const size = Number(sizeFt); + if (!Number.isFinite(size) || size <= 0) return 1; + return size >= 40 ? 1 : 0.5; +} + +/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */ +export function containersPerWagonForSize(sizeFt?: number | null): number { + return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt))); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 6424f013e..0b3334431 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -29,8 +29,7 @@ export class PriorityConfigsController { @Get('next-range') @RuleEngineView('priority-configs') @ApiOperation({ - summary: - "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + summary: 'Where the next contiguous range for a type (and currency) must start', }) nextRange( @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index a01ba4b5b..379997e6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -14,12 +13,6 @@ export class CreateContainerTypeDto { @Max(40) sizeFt!: number; - @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) - @IsNumber() - @Min(0.01) - @Transform(({ value }) => Number(value)) - wagonsPerUnit!: number; - @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 2347426ca..642f6942e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity { @Column({ name: 'size_ft', type: 'smallint', nullable: true }) sizeFt!: number; - @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) - wagonsPerUnit!: number; - @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 42ce389e1..4c40cae4b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -48,7 +48,6 @@ export class ContainerTypesService { code, label: dto.label, sizeFt: dto.sizeFt, - wagonsPerUnit: dto.wagonsPerUnit, isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts index 53cb7ed83..0b0ef1b0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for - * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range - * must start at the lowest uncovered wagon count. Caps: WAGON 50, - * CURRENCY 35, CUSTOMS 15. + * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( @@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => { ).rejects.toThrow(/overlaps existing rule/); }); - it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + it('imposes no upper ceiling on any type', async () => { await expect( - attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), - ).rejects.toThrow(/may not exceed 50/); + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, - maxWagonCount: 36, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 35/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, - maxWagonCount: 16, + maxWagonCount: 5000, }), - ).rejects.toThrow(/may not exceed 15/); + ).resolves.toBeUndefined(); }); - it('rejects any new rule once the chain covers the full range', async () => { + it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, - maxWagonCount: 51, + maxWagonCount: 120, }), - ).rejects.toThrow(/may not exceed 50/); + ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', - minWagonCount: 1, - maxWagonCount: 1, + minWagonCount: 16, + maxWagonCount: 99, }), - ).rejects.toThrow(/already cover the full 1–15 range/); + ).resolves.toBeUndefined(); + }); + + it('still rejects a min greater than the max', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), + ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { @@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => { it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); - await expect(svc.nextRange('WAGON')).resolves.toEqual({ - nextMin: 6, - maxCap: 50, - }); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); + // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), - ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, - maxCap: 35, }); }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index ff3711e42..274ce687f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,28 +10,19 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; -/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ -export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - /** * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range - * must start. Null when the chain is already complete up to the type's cap. + * must start. The chain is unbounded above, so there is always a next start. */ function nextRangeStart( rules: Pick[], -): number | null { - const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; +): number { const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); let next = 1; for (const r of sorted) { if (r.minWagonCount > next) break; // gap before this rule — fill it next = Math.max(next, r.maxWagonCount + 1); } - if (cap != null && next > cap) return null; return next; } @@ -102,8 +93,8 @@ export class PriorityConfigsService { * - ranges never overlap — a booking matches at most one rule per type; * - ranges are contiguous from 1: a new range must START at the lowest * wagon count not yet covered (after 1–5 the next is 6–…; deleting a - * middle rule opens a gap and the next create must fill it first); - * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * middle rule opens a gap and the next create must fill it first). + * There is no upper ceiling — max wagon count is unbounded. * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { @@ -118,14 +109,6 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const cap = RANGE_CAPS[input.type]; - if (input.maxWagonCount > cap) { - throw new BadRequestException( - `${input.type} ranges may not exceed ${cap} — ` + - `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, - ); - } - const siblings = ( await this.repository.findAll({ where: { type: input.type } }) ).filter( @@ -142,12 +125,6 @@ export class PriorityConfigsService { const currentStart = input.excludeId ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null : null; - if (expectedStart == null && currentStart == null) { - throw new BadRequestException( - `${input.type} rules already cover the full 1–${cap} range — ` + - 'delete or shrink an existing rule first.', - ); - } if ( input.minWagonCount !== expectedStart && input.minWagonCount !== currentStart @@ -174,21 +151,21 @@ export class PriorityConfigsService { } /** - * Where the next range for a type/currency must start, and the type's - * ceiling — feeds the create form so the min field is auto-filled and - * locked. `nextMin` is null when the chain already covers 1..cap. + * Where the next range for a type/currency must start — feeds the create + * form so the min field is auto-filled and locked. Always a number: the + * chain has no ceiling, so another range always fits. */ async nextRange( type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', currency?: string | null, - ): Promise<{ nextMin: number | null; maxCap: number }> { + ): Promise<{ nextMin: number }> { const siblings = ( await this.repository.findAll({ where: { type } }) ).filter( (s) => type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), ); - return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + return { nextMin: nextRangeStart(siblings) }; } async remove(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 8c3c92193..c19140d8a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 210, bookingContainers: [ - { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + { quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(3); @@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => { freightType: 'CONTAINER', cargoTotalWeightVgm: 40, bookingContainers: [ - { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + { quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } }, ], }; expect(service.wagonsFor(booking, dims)).toBe(2); @@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, + containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; @@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => { }); }); }); + +describe('BookingBatchService — built-train wagon capacity', () => { + // A schedule created from a built train is capped by its PHYSICAL consist: + // wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the + // old weight/length math would call every one of these trains FULL, so any + // assertion below that says "not full" proves those axes are ignored. + const scheduleId = 'schedule-built'; + + const reservedBooking = (id: string) => + ({ + id, + freightType: 'BULK', + cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload + bookingContainers: [], + originYardId: 'yard-a', + destinationYardId: 'yard-b', + }) as unknown as Booking; + + const buildService = (opts: { + physicalWagons: number; + reserved: Booking[]; + maxWagons?: number; + }) => { + const schedule = { + id: scheduleId, + maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose + bookingWindowStatus: 'OPEN', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: null, + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + train: { id: 'train-built-1' }, + }, + }; + const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; + const genericRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + const dataSource = { + getRepository: jest.fn((entity: { name?: string }) => + entity?.name === 'Wagon' ? wagonRepo : genericRepo, + ), + transaction: jest.fn(), + }; + const service = new BookingBatchService( + dataSource as never, + { + findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved), + } as never, + { + findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule), + findById: jest.fn().mockResolvedValue(schedule), + } as never, + null as never, + null as never, + null as never, + null as never, + null as never, + { emitPhase: jest.fn() } as never, + null as never, + ); + return { service, wagonRepo }; + }; + + it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => { + const { service } = buildService({ + physicalWagons: 2, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxWagons: 44, // stale: the old slot cap would say 42 slots remain + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + + it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + // 1T pull cap would have been exhausted long ago under the old math. + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { + const { service } = buildService({ + physicalWagons: 1, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + }); + await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({ + maxWagons: 1, + allocatedWagons: 2, + remainingSlots: 0, + overAllocatedBy: 1, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index ef39ab138..5beb920d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -68,6 +68,7 @@ import { wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; @@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit { private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, + // forwardRef: TrainSchedulingService injects this service back (window + // refresh after adjust-consist), so the classes load in a cycle. + @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit { ? Math.ceil(booking.wagonsRequired) : 0; - // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); @@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit { } /** - * Keep schedule.max_wagons aligned with the train's boarding limit: the - * locomotive's length-derived slot count. The physical wagons currently in - * the train set do NOT cap this — bookings are admitted on length/weight - * alone and yard staff attach the wagons manually before departure. + * Keep schedule.max_wagons aligned with the train's boarding limit. A built + * train's limit is its physical consist — the wagon count staff marshalled + * (and may change via adjust-consist). Only schedules WITHOUT a built train + * fall back to the locomotive's length-derived slot count, where bookings + * are admitted on length/weight alone and yard staff attach the wagons + * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, ): Promise { - const limits = await this.capacityLimits(locomotive); - const maxWagons = limits.base.wagons; + const physicalWagons = await this.builtTrainWagonCount(schedule); + const maxWagons = + physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) @@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit { * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * - * The wagon axis is the locomotive's length-derived slot count only — the - * physical wagons currently marshalled in the train set do NOT cap it. - * Bookings are admitted on length/weight capacity and yard staff attach - * the missing wagons manually before wagon assignment. + * Two capacity regimes, decided by the schedule's train: + * - Built train (Train Builder consist with physical wagons): the consist IS + * the capacity. Wagon slots = physical wagon count; weight and length are + * NOT re-checked here — the builder and adjust-consist already enforced the + * locomotive's pull/length limits when the consist was assembled. + * - No built train (legacy schedules): the locomotive's length-derived slot + * count plus its weight/length budgets, as before — yard staff attach the + * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, ): Promise { + const physicalWagons = await this.builtTrainWagonCount(schedule); + if (physicalWagons != null) { + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) @@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit { return budget; } + /** + * Physical wagons marshalled in the schedule's built train, or null when the + * schedule has no built train (or the consist is still empty) and the legacy + * locomotive-derived capacity must apply. This count is what caps a built + * train's bookings: 50 wagons coupled → 50 wagon slots, no more. + */ + private async builtTrainWagonCount( + schedule: TrainSchedule, + ): Promise { + const trainId = schedule.trainSet?.train?.id; + if (!trainId) return null; + const count = await this.dataSource + .getRepository(Wagon) + .count({ where: { trainId } }); + return count > 0 ? count : null; + } + /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide @@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / - * train length for even one more loaded wagon. The old slot-only check let - * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of - * 3500+90T, slots bind at 44) cycle its booking window forever instead of - * finalizing — 7 phantom slots kept it "not full" while nothing could board. + * Built train: FULL when every physical wagon slot is taken — the consist is + * the capacity, weight/length were settled at build time. + * No built train: FULL on ANY capacity axis — out of wagon slots, or out of + * pull weight / train length for even one more loaded wagon. The old + * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = + * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever + * instead of finalizing — 7 phantom slots kept it "not full" while nothing + * could board. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit { return this.isTrainFull(schedule); } + /** + * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the + * schedule's slot capacity, how many slots allocated + reserved bookings + * already hold on the busiest edge, how many are still free on the most-open + * edge, and by how many slots the consist has been trimmed BELOW what is + * already committed (0 when nothing is over-allocated). + */ + async scheduleWagonUsage(scheduleId: string): Promise<{ + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return null; + const capacity = + (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; + const wagonDims = await this.loadWagonDims(); + const budget = await this.remainingBudget( + schedule, + { + base: { + wagons: capacity, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }, + wagonDims, + ); + const tightest = budget.remainingFor(budget.fullLeg()).wagons; + return { + maxWagons: capacity, + allocatedWagons: capacity - tightest, + remainingSlots: Math.max(0, budget.maxRemaining().wagons), + overAllocatedBy: Math.max(0, -tightest), + }; + } + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { if ((await this.remainingWagons(schedule)) <= 0) return true; + // Built train: the physical consist is the only capacity axis. Weight and + // length were enforced when the consist was assembled (builder / + // adjust-consist), so a free wagon slot means the train genuinely has room. + if ((await this.builtTrainWagonCount(schedule)) != null) return false; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against const wagonDims = await this.loadWagonDims(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 9cca4d213..4d408689b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n } // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 - // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // wagon). Derived from containerType.sizeFt; falls back to the line's stored // fraction. Ceiling per line would over-count split 20ft lines. return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b92a7ea90..614c4fabd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -95,6 +97,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingBatchService } from './booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -317,6 +320,11 @@ export class TrainSchedulingService { private readonly bookingNotifier: BookingNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, + // forwardRef: BookingBatchService injects this service back; @Optional so + // existing specs that construct the service without it keep working. + @Optional() + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService?: BookingBatchService, ) {} /** @@ -5054,6 +5062,11 @@ export class TrainSchedulingService { wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), ); + // Wagon-slot picture for the dialog: the consist IS the schedule's booking + // capacity, so trimming/coupling wagons moves the FULL line live. + const wagonUsage = + (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null; + const mapWagon = (wagon: Wagon) => ({ id: wagon.id, wagonNumber: wagon.wagonNumber, @@ -5092,6 +5105,12 @@ export class TrainSchedulingService { grossTons: roundTons(cargoTons + consistTareTons), consistLengthMeters, }, + scheduleCapacity: wagonUsage + ? { + ...wagonUsage, + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + } + : null, wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), @@ -5284,7 +5303,37 @@ export class TrainSchedulingService { ); }); - return this.getScheduleConsist(scheduleId); + // The consist IS the schedule's booking capacity, so an edit moves the + // FULL line: freeing slots on a FULL schedule reopens its window, taking + // the last slot closes it. Staff may shrink below what is already + // committed — allowed, but reported back as a warning (never silently). + const warnings: string[] = []; + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId); + if (usage) { + const nowFull = usage.remainingSlots <= 0; + if (usage.overAllocatedBy > 0) { + warnings.push( + `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` + + `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` + + 'Couple more wagons or free bookings before departure.', + ); + } + if (wasFull && !nowFull) { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + warnings.push( + `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` + + 'so it is no longer FULL and can take bookings again.', + ); + } else if (!wasFull && nowFull) { + await this.bookingBatchService?.setWindow(scheduleId, 'FULL'); + warnings.push( + 'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.', + ); + } + } + + return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 157666f55..bb9adf319 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => { fortyFooter.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40, - wagonsPerUnit: 1, } as never; const result = planWagonsWithStock({ bookings: [fortyFooter], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 19e19dca3..c3d48f286 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -106,7 +106,7 @@ describe('wagon-plan.util', () => { }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { - // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); @@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, - containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { @@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); - it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 78cd8bf54..bb5ce890c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -1,6 +1,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { consistViolations } from './train-capacity.util'; @@ -61,7 +62,6 @@ export type ContainerUnitRow = { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; @@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; } -export function containersPerWagonFromType(wagonsPerUnit: number): number { - const wpu = Number(wagonsPerUnit); - if (!wpu || wpu <= 0) return 1; - return Math.max(1, Math.round(1 / wpu)); -} - type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; - containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; + containerType?: { sizeFt?: number | null } | null; }; /** - * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit - * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so - * the BOOKING total is ceiled once — ceiling per line over-counts a booking that - * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + * RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived + * fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept + * fractional so the BOOKING total is ceiled once — ceiling per line over-counts a + * booking that splits its 20ft units across several lines (3×20 + 3×20 = 3 + * wagons, not 4). */ function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; - const wpu = Number(line.containerType?.wagonsPerUnit); - if (Number.isFinite(wpu) && wpu > 0) { - return qty * wpu; + const sizeFt = Number(line.containerType?.sizeFt); + if (Number.isFinite(sizeFt) && sizeFt > 0) { + return qty * wagonsPerUnitForSize(sizeFt); } - // No wagonsPerUnit on the type: fall back to the line's stored fraction, else - // treat the whole line as one wagon. + // No size on the type: fall back to the line's stored fraction, else treat + // the whole line as one wagon. const stored = Number(line.wagonsRequired); return Number.isFinite(stored) && stored > 0 ? stored : 1; } @@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const qty = Number(line.quantity ?? 0); const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); - const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); - const perWagon = containersPerWagonFromType(wagonsPerUnit); + const perWagon = containersPerWagonForSize(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt); // The REAL per-container numbers/weights entered at booking time. Unit i of // the line maps to units[i] (sortOrder order); the line-level number is only @@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, - wagonsPerUnit, containersPerWagon: perWagon, teuSlots, containerNumber: diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index a8abee67b..dbfd5cee6 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -209,7 +209,6 @@ async function ensureReferences(manager: any) { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 4f801330a..9cf1ef0cd 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -118,7 +118,6 @@ async function main() { code: '40FT', label: '40FT', sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts index 367b79b44..b14ac8c15 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-export-receive-ready.ts @@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -115,7 +116,7 @@ async function main() { reeferQuantity: 0, vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)), totalVgmTons: Number((weightKg / 1000).toFixed(3)), - wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)), + wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)), isOverweight: false, }), ); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 989e18bf1..2e234bcf0 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -11,6 +11,7 @@ import { } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { } const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 3720c0e2a..20d2a3f8b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; import { Container } from "../modules/container-management/entities/container.entity"; import { Route } from "../modules/routes/entities/route.entity"; @@ -300,7 +301,6 @@ export class DemoBookingsSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -400,7 +400,7 @@ export class DemoBookingsSeeder { .getRepository(BookingContainer) .delete({ bookingId: booking.id }); const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index e1c46d168..f60d7bb75 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity'; import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util'; import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder { await manager.getRepository(ContainerType).upsert( CONTAINER_TYPES.map((containerType, index) => ({ ...containerType, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder { const isImport = demoBooking.tradeDirection === 'IMPORT'; const wagonsRequired = - Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt); const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 872e909bb..147f2ae54 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,6 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -125,7 +124,6 @@ export class PricingDataSeeder { code: "40FT", label: "40FT Standard", sizeFt: 40, - wagonsPerUnit: 1, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +133,6 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -145,7 +142,6 @@ export class PricingDataSeeder { code: "40FT_REEFER", label: "40FT Reefer", sizeFt: 40, - wagonsPerUnit: 1, isReefer: true, isOpenTop: false, isActive: true, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0da9fe3f0..03449fbb2 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -197,20 +197,20 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, // Operations Path A queue: per-booking self-clearance review for // GENERAL non-customs booking instances (and legacy self-clear bookings). - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx index 1ca91e01f..14dd60912 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -65,7 +65,10 @@ export default function AdjustConsistModal({ } }, [opened]); - // Live projection: gross = cargo + tare of (consist − trims + adds). + // Live projection: gross = cargo + tare of (consist − trims + adds), plus + // the schedule's wagon-slot picture — the consist IS the booking capacity + // (weight/length only bind while assembling the consist), so trims/adds + // move the FULL line in real time. const projection = useMemo(() => { if (!data) return null; const removed = new Set(removeIds); @@ -79,8 +82,11 @@ export default function AdjustConsistModal({ const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0); const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); const gross = round2(data.totals.cargoTons + tare); + const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length; + const cap = data.scheduleCapacity; + const freeSlots = cap ? wagonCount - cap.allocatedWagons : null; return { - wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, + wagonCount, tare: round2(tare), gross, length: round2(length), @@ -93,16 +99,33 @@ export default function AdjustConsistModal({ overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, overLength: data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, + slots: + cap && freeSlots != null + ? { + allocated: cap.allocatedWagons, + free: freeSlots, + pct: + wagonCount > 0 + ? Math.round((cap.allocatedWagons / wagonCount) * 100) + : null, + isFullNow: cap.bookingWindowStatus === "FULL", + willBeFull: freeSlots <= 0, + overAllocated: freeSlots < 0, + willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0, + } + : null, }; }, [data, removeIds, addIds]); + const hasChanges = removeIds.length > 0 || addIds.length > 0; + const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); const handleSubmit = async () => { if (!removeIds.length && !addIds.length) return; try { - await adjust.mutateAsync({ + const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), @@ -114,6 +137,18 @@ export default function AdjustConsistModal({ removeIds.length && addIds.length ? ", " : "" }${addIds.length ? `${addIds.length} added` : ""}`, }); + // Schedule-impact warnings from the API: window reopened / now FULL / + // consist trimmed below what bookings already hold. + for (const warning of result.warnings ?? []) { + toast({ + title: "Schedule capacity", + description: warning, + duration: 8000, + ...(warning.includes("over capacity") + ? { variant: "destructive" as const } + : {}), + }); + } setRemoveIds([]); setAddIds([]); } catch (err) { @@ -169,8 +204,57 @@ export default function AdjustConsistModal({ over={projection?.overLength ?? false} /> + {projection?.slots ? ( + + 0 + ? ` — ${projection.slots.free} free` + : projection.slots.free === 0 + ? " — none free (FULL)" + : "" + }`} + pct={projection.slots.pct} + over={projection.slots.overAllocated} + /> + + ) : null} + {projection?.slots?.isFullNow && !hasChanges ? ( + }> + This schedule is FULL — all {projection.wagonCount} wagon slots are + taken. You can still edit the train: coupling wagons adds capacity + and reopens booking; trimming free wagons keeps it FULL. + + ) : null} + {hasChanges && projection?.slots?.overAllocated ? ( + }> + This change leaves {-projection.slots.free} booked wagon(s) without + a slot — bookings already hold {projection.slots.allocated} of the{" "} + {projection.wagonCount} remaining. You can apply it, but couple + wagons back or free bookings before departure. + + ) : null} + {hasChanges && + projection?.slots && + !projection.slots.overAllocated && + projection.slots.willBeFull && + !projection.slots.isFullNow ? ( + }> + This change takes the last free wagon slot — the schedule becomes + FULL and stops accepting bookings. + + ) : null} + {hasChanges && projection?.slots?.willReopen ? ( + }> + This schedule is currently FULL — applying frees{" "} + {projection.slots.free} wagon slot(s) and reopens its booking + window. + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts index 3d42c8684..5072ce34a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/containerPlacement.util.spec.ts @@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling'; function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] { const units: ContainerUnitRow[] = []; const containersPerWagon = sizeFt >= 40 ? 1 : 2; - const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5; for (let i = 0; i < quantity; i++) { units.push({ @@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con label: `${containerType} ${i + 1}/${quantity}`, grossWeightTons: 25, sizeFt, - wagonsPerUnit, containersPerWagon, teuSlots: sizeFt >= 40 ? 2 : 1, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 07a6c72cd..30fdcb9c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -61,7 +61,6 @@ interface RefContainerType { name: string; code: string; is_reefer?: boolean; - wagons_per_unit?: number; } interface RefContainerGroup { size: string; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index c7c76b91b..902d23427 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ label: "Max wagon count", type: "number", required: true, - description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + description: "No upper limit — must be at least the min wagon count", }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts index f387fc8dc..9357e1967 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -1,20 +1,14 @@ /** * Client mirror of the backend's contiguous-range rules for priority configs * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per - * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next - * range always starts at the lowest uncovered wagon count. The backend - * re-validates on submit AND on approval; this only drives the form prefill. + * currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. There is no upper + * ceiling. The backend re-validates on submit AND on approval; this only + * drives the form prefill. */ export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; -/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ -export const PRIORITY_RANGE_CAPS: Record = { - WAGON: 50, - CURRENCY: 35, - CUSTOMS: 15, -}; - export interface PriorityRangeRule { id?: unknown; type?: unknown; @@ -23,10 +17,13 @@ export interface PriorityRangeRule { maxWagonCount?: unknown; } +const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"]; + /** * Where the next range for `type` (+`currency`) must start, excluding - * `excludeId` (the rule being edited). Null when the chain already covers - * 1..cap — no further rule fits. + * `excludeId` (the rule being edited). Null only when `type` is not yet a + * known priority rule type — the chain itself is unbounded, so a next start + * always exists. */ export function nextPriorityRangeStart( rules: PriorityRangeRule[], @@ -34,8 +31,7 @@ export function nextPriorityRangeStart( currency: string | null | undefined, excludeId?: string, ): number | null { - const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; - if (!cap) return null; + if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null; const scoped = rules .filter( @@ -56,5 +52,5 @@ export function nextPriorityRangeStart( if (r.min > next) break; // gap before this rule — fill it first next = Math.max(next, r.max + 1); } - return next > cap ? null : next; + return next; } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7a2a478c1..71db16fb5 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service"; import { trainBuilderService, type AdjustConsistPayload, + type AdjustConsistResult, type AvailableTrain, type BuildTrainPayload, type BuiltTrainListFilters, @@ -338,7 +339,7 @@ export const api = { adjustConsist: endpoint< { scheduleId: string; payload: AdjustConsistPayload }, - ScheduleConsist + AdjustConsistResult >( "train-scheduling", "adjust-consist", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 62ae9f7a3..083122a10 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -235,6 +235,18 @@ export interface ScheduleConsist { occurredAt: string; }>; editable: boolean; + /** + * Wagon-slot picture of the schedule: the consist IS the booking capacity + * (weight/length only bind while building the consist), so the dialog can + * project FULL / reopen / over-allocation live. Null on legacy schedules. + */ + scheduleCapacity: { + maxWagons: number; + allocatedWagons: number; + remainingSlots: number; + overAllocatedBy: number; + bookingWindowStatus: string | null; + } | null; } export interface AdjustConsistPayload { @@ -242,6 +254,9 @@ export interface AdjustConsistPayload { removeWagonIds?: string[]; } +/** Adjust response: fresh consist + schedule-impact warnings to surface. */ +export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; + export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), @@ -275,7 +290,7 @@ export const trainBuilderService = { apiClient.get(`/train-scheduling/schedules/${scheduleId}/consist`), /** Permanently trim/add wagons on the schedule's built train. */ adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) => - apiClient.post( + apiClient.post( `/train-scheduling/schedules/${scheduleId}/adjust-consist`, payload, ), diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 31e1c6a04..b83aa33ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -72,7 +72,6 @@ export interface ContainerUnitRow { label: string; grossWeightTons: number; sizeFt?: number; - wagonsPerUnit?: number; containersPerWagon?: number; teuSlots?: number; containerNumber?: string | null; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dc973d9c1..e84fa6ca1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -853,7 +853,6 @@ export interface BookingReferenceContainerType { name: string; code: string; is_reefer: boolean; - wagons_per_unit: number; } export interface BookingReferenceContainerSizeGroup { From 18311f22f7cc169787e21aacf8d5655efb8b8928 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 09:20:36 +0000 Subject: [PATCH 06/16] Comment out payment event handling for local demos in BillingService --- apps/edr-freight-api/src/modules/payment/payment.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 5b8a3ddca..d96bcf492 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -190,12 +190,16 @@ export class PaymentService { */ async initiate(input: InitiateIntentInput): Promise { try { + + + const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - amountMinor: input.amountMinor, + // amountMinor: input.amountMinor, + amountMinor:1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, From 84d5add58e87e22e99ab6963ca3aaadac90f5a62 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 09:47:37 +0000 Subject: [PATCH 07/16] fix four digit --- .../src/pages/fleet/config/resources.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index baeaa99a5..7a473fdb1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -126,20 +126,22 @@ const WAGON_STATUS_OPTIONS = [ /** * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing - * an export number fully determines the import one. Listed explicitly rather - * than computed as export+1, so a pair that ever breaks that convention stays - * correct here. + * an export number fully determines the import one. + * + * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out + * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks + * the convention stays correct here. */ const TRAIN_RUN_PAIRS: Record = { "8001": "8002", - "81001": "81002", - "82001": "82002", - "83001": "83002", - "84001": "84002", - "85001": "85002", - "86001": "86002", - "87001": "87002", - "88001": "88002", + "8101": "8102", + "8201": "8202", + "8301": "8302", + "8401": "8402", + "8501": "8502", + "8601": "8602", + "8701": "8702", + "8801": "8802", "8901": "8902", "9001": "9002", }; @@ -294,12 +296,23 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", - searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], + searchKeys: [ + "wagonNumber", + "wagonTypeId", + "trainId", + "exportTrainNumber", + "importTrainNumber", + "status", + "currentYardId", + ], columns: [ // Tare weight and payload capacity are not wagon columns — they belong to the // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, + // Unset on a wagon that is not on a run — renders as a dimmed dash. + { id: "exportTrainNumber", header: "Export train no.", accessorKey: "exportTrainNumber", format: "code" }, + { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], From a9d65e04fc34aa4d3ebf385a9e7a5842ec4eb8c4 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 10:12:43 +0000 Subject: [PATCH 08/16] fix number of wagon --- .../2280000000000-SeedWagonRunNumbers.ts | 206 ++++++++++++++++++ .../src/scripts/seed-edr-wagons.ts | 34 ++- 2 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts diff --git a/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts new file mode 100644 index 000000000..dae700883 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2280000000000-SeedWagonRunNumbers.ts @@ -0,0 +1,206 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Assign EDR export/import run numbers to the wagon fleet. + * + * Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every + * wagon with NULL run numbers — so this must stay later in timestamp order. + * + * Source data below is the operator-supplied roster, kept verbatim rather than + * pre-resolved so its quirks stay visible: + * - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50). + * - Four wagons are claimed by two runs each. A wagon holds a single run, so + * FIRST-LISTED WINS, which is why four runs land one short of their listed + * count: + * ER0484 8301 over 8401 + * ER0451 8401 over 8701 + * ER0887 8701 over 9001 + * ER0936 8801 over 8901 + * + * Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs. + */ + +/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */ +const RUN_WAGONS: Record = { + '8001': [ + 'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901', + 'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840', + 'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694', + 'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868', + 'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826', + 'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825', + 'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782', + 'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519', + 'ER0479', 'ER0440', + ], + '8101': [ + 'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459', + 'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768', + 'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937', + 'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590', + 'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435', + 'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633', + 'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520', + 'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880', + 'ER0422', 'ER0852', + ], + '8201': [ + 'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618', + 'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625', + 'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231', + 'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464', + 'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733', + 'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588', + 'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928', + 'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236', + 'ER0933', 'ER0456', + ], + '8301': [ + 'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780', + 'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818', + 'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485', + 'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762', + 'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528', + 'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232', + 'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622', + 'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513', + ], + '8401': [ + 'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758', + 'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434', + 'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740', + 'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787', + 'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530', + 'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563', + 'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442', + 'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614', + 'ER0561', 'ER0393', + ], + '8501': [ + 'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748', + 'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433', + 'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508', + 'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572', + 'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814', + 'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418', + 'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702', + 'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483', + 'ER0824', 'ER0640', 'ER0714', + ], + '8601': [ + 'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808', + 'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922', + 'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496', + 'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667', + 'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711', + 'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487', + 'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257', + ], + '8701': [ + 'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665', + 'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582', + 'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680', + 'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900', + 'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726', + 'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705', + 'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655', + 'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861', + 'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315', + 'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693', + 'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518', + 'ER0887', + ], + '8801': [ + 'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476', + 'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501', + 'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601', + 'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896', + 'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895', + 'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610', + 'ER0275', 'ER0333', 'ER0344', 'ER0469', + ], + '8901': [ + 'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441', + 'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453', + 'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866', + 'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908', + 'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478', + 'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497', + 'ER0643', 'ER0638', 'ER0468', 'ER0597', + ], + '9001': [ + 'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672', + 'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912', + 'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399', + 'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865', + 'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574', + 'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699', + 'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259', + 'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927', + 'ER0810', 'ER0681', 'ER0887', + ], +}; + +/** + * Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather + * than computed as export+1 so a run that ever breaks the convention stays + * correct. Run numbers are always 4 digits (8401, never 84001). + */ +const IMPORT_RUN: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +export class SeedWagonRunNumbers2280000000000 implements MigrationInterface { + name = 'SeedWagonRunNumbers2280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Idempotent: clear the roster's runs first so a re-run cannot leave a + // wagon on a run it was since moved off of. + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + + const claimed = new Set(); + + for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) { + const importRun = IMPORT_RUN[exportRun]; + if (!importRun) throw new Error(`import_run_missing:${exportRun}`); + + // First-listed wins — skip any wagon an earlier run already claimed. + const fresh = wagons.filter((w) => !claimed.has(w)); + fresh.forEach((w) => claimed.add(w)); + if (!fresh.length) continue; + + await queryRunner.query( + ` + UPDATE freight.wagons + SET export_train_number = $1, + import_train_number = $2, + updated_at = now() + WHERE wagon_number = ANY($3::text[]); + `, + [exportRun, importRun, fresh], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons + SET export_train_number = NULL, import_train_number = NULL + WHERE export_train_number IS NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index 716532165..eb39bbc87 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,7 @@ import { AppDataSource } from '../data-source'; import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; +import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers'; +import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,7 +12,12 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); + // Fleet first (recreates every wagon with NULL runs), then the columns are + // ensured to exist, then the run roster is applied on top. Same order the + // migrations run in, so the script and a fresh migrate agree. await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); + await new AddWagonTrainNumbers2270000000000().up(queryRunner); + await new SeedWagonRunNumbers2280000000000().up(queryRunner); const summary = await queryRunner.query(` SELECT @@ -20,7 +27,8 @@ async function seedEdRWagons() { MIN(w.wagon_number) AS first_wagon, MAX(w.wagon_number) AS last_wagon, COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, - COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard, + COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' @@ -29,13 +37,33 @@ async function seedEdRWagons() { `); const [totals] = await queryRunner.query(` - SELECT COUNT(*)::int AS total FROM freight.wagons; + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run + FROM freight.wagons; + `); + + const runs = await queryRunner.query(` + SELECT + export_train_number AS export_run, + import_train_number AS import_run, + COUNT(*)::int AS wagons + FROM freight.wagons + WHERE export_train_number IS NOT NULL + GROUP BY export_train_number, import_train_number + ORDER BY export_train_number; `); await queryRunner.commitTransaction(); + console.log('\nFleet by wagon type:'); console.table(summary); - console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); + console.log('Run roster (export/import pairs):'); + console.table(runs); + console.log( + `Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` + + `${totals.on_a_run} on a run (expected 533).`, + ); } catch (error) { await queryRunner.rollbackTransaction(); throw error; From 801872c1065b7597a0b16e2be238fddbef134892 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 10:15:26 +0000 Subject: [PATCH 09/16] fix rate edit --- .../src/common/rule-engine-guards.ts | 11 + .../2300000000000-CreateRateChangeRequests.ts | 47 ++++ .../rate-change-requests.controller.ts | 58 ++++ .../dto/rate-change-request.dto.ts | 28 ++ .../entities/rate-change-request.entity.ts | 54 ++++ .../modules/rule-engine/rule-engine.module.ts | 6 + .../rate-change-requests.service.spec.ts | 213 +++++++++++++++ .../services/rate-change-requests.service.ts | 241 +++++++++++++++++ .../rule-engine/services/rates.service.ts | 58 +++- ...pdate-train-scheduling-global-rules.dto.ts | 28 -- .../train-scheduling.service.ts | 29 +- .../src/seed/freight-permissions.registry.ts | 17 ++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../src/hooks/rule-engine/useRuleEngine.ts | 66 +++++ .../backoffice/src/lib/permissions.ts | 15 ++ .../pages/ruleEngine/RateApprovalsSection.tsx | 247 ++++++++++++++++++ .../ruleEngine/RuleEngineResourcePage.tsx | 92 ++++++- .../TrainSchedulingGlobalRulesPage.tsx | 62 ----- .../services/ruleEngine/ruleEngine.service.ts | 62 +++++ .../backoffice/src/types/trainScheduling.ts | 4 - 20 files changed, 1227 insertions(+), 112 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 12ba30e11..14c0385ee 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { FreightPermissionGuard } from './freight-permission.guard'; import { FREIGHT_PERMS, + type RuleEngineApprovableSlug, type RuleEngineResourceSlug, } from '../seed/freight-permissions.registry'; @@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) => applyDecorators( UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), ); + +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Super admins pass any freight permission check, so + * approvals work before the permission is granted to a director role. + */ +export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])), + ); diff --git a/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts new file mode 100644 index 000000000..a3e36f0b9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-CreateRateChangeRequests.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for edits to LIVE rates. A LIVE rate is what pricing + * charges, so it is never edited in place: the edit is filed here as PENDING + * and the live row keeps its value until an approver applies it. + * + * `payload` holds the changed fields only; `previous_values` snapshots what + * they were at submit time so the approver sees a real before→after diff. + */ +export class CreateRateChangeRequests2300000000000 implements MigrationInterface { + name = 'CreateRateChangeRequests2300000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.rate_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rate_id uuid NOT NULL REFERENCES freight.rates (id), + payload jsonb NOT NULL, + previous_values jsonb NOT NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rcr_status + ON freight.rate_change_requests (status) + `); + // At most one pending edit per rate — two racing requests would both pass + // validation and the second would silently overwrite the first on approval. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate + ON freight.rate_change_requests (rate_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts new file mode 100644 index 000000000..9972ab06a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { isSuperAdmin } from '../../../common/freight-permission.util'; +import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { RateChangeStatus } from '../entities/rate-change-request.entity'; +import { RateChangeRequestsService } from '../services/rate-change-requests.service'; + +/** + * Edits to LIVE rates. Staff with `manage` propose (submit); only holders of + * `approve` decide. Until a change is approved the live rate keeps its current + * value, so pricing never moves on an unapproved edit. + */ +@ApiTags('rate-change-requests') +@Controller('rate-change-requests') +@ApiBearerAuth() +export class RateChangeRequestsController { + constructor(private readonly service: RateChangeRequestsService) {} + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) + submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rate change requests, optionally by status' }) + list(@Query('status') status?: RateChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Approve a rate change and put it into effect' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user)); + } + + @Post(':id/reject') + @RuleEngineApprove('rates') + @ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecideRateChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts new file mode 100644 index 000000000..6c5dc3fbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/rate-change-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +import { UpdateRateDto } from './update-rate.dto'; + +export class SubmitRateChangeDto { + @ApiProperty({ description: 'The LIVE rate to reprice' }) + @IsUUID() + rateId!: string; + + @ApiProperty({ + description: + 'Proposed field changes. The live rate keeps its current values until this is approved.', + type: UpdateRateDto, + }) + @ValidateNested() + @Type(() => UpdateRateDto) + update!: UpdateRateDto; +} + +export class DecideRateChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts new file mode 100644 index 000000000..00a1c0ba3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-change-request.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Rate } from './rate.entity'; + +export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed edit to a LIVE rate, awaiting approval. + * + * A LIVE rate is what pricing actually charges, so it is never mutated in + * place: the edit is filed here and the live row keeps its old value until an + * approver applies it. `payload` holds only the changed fields (an + * UpdateRateDto patch), `rateId` the rate being repriced. + * + * DRAFT rates are not covered — nothing prices off a draft, so those still + * edit directly and reach LIVE through the existing submit/approve flow. + */ +@Entity({ schema: 'freight', name: 'rate_change_requests' }) +@Index(['status']) +export class RateChangeRequest extends BaseEntity { + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate, { nullable: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate | null; + + /** Proposed field changes — an UpdateRateDto patch, changed keys only. */ + @Column({ name: 'payload', type: 'jsonb' }) + payload!: Record; + + /** + * The rate's values at submit time, for the approver's before→after diff. + * Snapshotted because the live row can move on between submit and decision. + */ + @Column({ name: 'previous_values', type: 'jsonb' }) + previousValues!: Record; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: RateChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 7edcf0bbf..39a5450ef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; +import { RateChangeRequestsController } from './controllers/rate-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -17,6 +18,7 @@ import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; +import { RateChangeRequest } from './entities/rate-change-request.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; @@ -49,6 +51,7 @@ import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; +import { RateChangeRequestsService } from './services/rate-change-requests.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; @@ -72,6 +75,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerType, PriorityConfig, PriorityRuleChangeRequest, + RateChangeRequest, ServiceType, WeightLimitRule, Yard, @@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesController, PriorityConfigsController, PriorityRuleChangeRequestsController, + RateChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -121,6 +126,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ContainerTypesService, PriorityConfigsService, PriorityRuleChangeRequestsService, + RateChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts new file mode 100644 index 000000000..6c3adce66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; + +import { RateChangeRequest } from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RateChangeRequestsService } from './rate-change-requests.service'; + +/** + * The guarantee under test: editing a LIVE rate never moves the live value. + * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only + * approval applies it, and only then through RatesService (so every rate rule + * is re-checked against the state at approval time). + */ +describe('RateChangeRequestsService', () => { + const liveRate = (overrides: Partial = {}): Rate => + ({ + id: 'rate-1', + status: 'LIVE', + rateType: 'OCEAN_FREIGHT', + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + currency: 'USD', + // Postgres numeric comes back as a string — the no-op check must cope. + rateValue: '100.0000' as unknown as number, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: null, + proposedByStaffId: 'staff-1', + ...overrides, + }) as unknown as Rate; + + const build = (opts: { + rate?: Rate; + pending?: RateChangeRequest | null; + applyThrows?: Error; + } = {}) => { + const rate = opts.rate ?? liveRate(); + const saved: RateChangeRequest[] = []; + + const repo = { + findOne: jest.fn(async ({ where }: { where: Record }) => { + if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null; + return saved.find((r) => r.id === where.id) ?? opts.pending ?? null; + }), + create: jest.fn((data: Partial) => ({ id: 'req-1', ...data })), + save: jest.fn(async (entity: RateChangeRequest) => { + saved.push(entity); + return entity; + }), + find: jest.fn(async () => saved), + }; + + const rates = { + findById: jest.fn(async () => rate), + assertUpdateValid: jest.fn(async () => undefined), + applyApprovedUpdate: jest.fn(async () => { + if (opts.applyThrows) throw opts.applyThrows; + return rate; + }), + }; + + const inbox = { notify: jest.fn(async () => undefined) }; + + const service = new RateChangeRequestsService( + repo as never, + rates as never, + inbox as never, + ); + // `pending` is the very object approve/reject mutate — assert on it, not a copy. + return { service, repo, rates, inbox, pending: opts.pending }; + }; + + describe('submit', () => { + it('files a pending request instead of touching the live rate', async () => { + const { service, rates } = build(); + + const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }); + + expect(request.status).toBe('PENDING'); + expect(request.payload).toEqual({ rateValue: 200 }); + // The old value is snapshotted for the approver's diff... + expect(request.previousValues).toEqual({ rateValue: '100.0000' }); + // ...and nothing wrote to the rate itself. + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('keeps only the fields that actually changed', async () => { + const { service } = build(); + + // A form posts every field back; only rateValue differs from the live rate. + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 200, + currency: 'USD', + rateUnit: 'PER_CONTAINER', + appliesTo: 'CONTAINER', + }, + }); + + expect(request.payload).toEqual({ rateValue: 200 }); + }); + + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { + const { service } = build(); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }), + ).rejects.toThrow(/Nothing changed/); + }); + + it('refuses a rate that is not LIVE — those edit directly', async () => { + const { service } = build({ rate: liveRate({ status: 'DRAFT' }) }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(BadRequestException); + }); + + it('refuses a second pending change for the same rate', async () => { + const { service } = build({ + pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest, + }); + await expect( + service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }), + ).rejects.toThrow(ConflictException); + }); + + it('validates up front so the requester hears about a bad patch, not the approver', async () => { + const { service, rates } = build(); + rates.assertUpdateValid.mockRejectedValueOnce( + new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + ); + await expect( + service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), + ).rejects.toThrow(/not valid for this rate/); + }); + }); + + describe('approve', () => { + const pendingRequest = (): RateChangeRequest => + ({ + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + }) as unknown as RateChangeRequest; + + it('applies the change through RatesService and marks it approved', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + + const decided = await service.approve('req-1', 'approver-1', 'Agreed'); + + expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 }); + expect(decided.status).toBe('APPROVED'); + expect(decided.decidedByUserId).toBe('approver-1'); + expect(decided.decisionNote).toBe('Agreed'); + }); + + it('blocks the requester from approving their own change', async () => { + const { service, rates } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + + it('lets a super admin self-approve', async () => { + const { service } = build({ pending: pendingRequest() }); + await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({ + status: 'APPROVED', + }); + }); + + it('stays PENDING when applying now fails — never marks a change that did not land', async () => { + const { service, pending, repo } = build({ + pending: pendingRequest(), + applyThrows: new ConflictException('A rate for this exact combination already exists.'), + }); + + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/); + // Apply runs first, so a failure leaves the request untouched and re-decidable. + expect(pending!.status).toBe('PENDING'); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('refuses to decide an already-decided request', async () => { + const { service } = build({ + pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest, + }); + await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException); + }); + }); + + describe('reject', () => { + it('never touches the rate — it simply keeps its current value', async () => { + const { service, rates } = build({ + pending: { + id: 'req-1', + rateId: 'rate-1', + payload: { rateValue: 200 }, + previousValues: { rateValue: '100.0000' }, + status: 'PENDING', + requestedByUserId: 'staff-1', + } as unknown as RateChangeRequest, + }); + + const decided = await service.reject('req-1', 'approver-1', 'Too steep'); + + expect(decided.status).toBe('REJECTED'); + expect(decided.decisionNote).toBe('Too steep'); + expect(rates.applyApprovedUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts new file mode 100644 index 000000000..357c67f95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -0,0 +1,241 @@ +import { NotificationAudience, NotificationType } from '@edr/types'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { + RateChangeRequest, + RateChangeStatus, +} from '../entities/rate-change-request.entity'; +import { Rate } from '../entities/rate.entity'; +import { RatesService } from './rates.service'; + +/** Backoffice page where both the queue and the rates live. */ +const RATES_LINK = '/dashboard/rules/rates'; + +/** Fields a change request may carry — anything else in the patch is ignored. */ +const DIFFABLE_FIELDS = [ + 'rateValue', + 'currency', + 'rateUnit', + 'appliesTo', + 'trigger', + 'tradeDirection', + 'containerTypeId', + 'cargoTypeId', +] as const; + +/** + * Approval workflow for edits to LIVE rates. + * + * A LIVE rate is what pricing charges right now, so it is never edited in + * place. The edit is filed here as a PENDING request and the live row keeps + * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 + * waits. Approval replays the edit through RatesService, so every rule + * (unit validity, pattern uniqueness) is re-checked against whatever is true + * at approval time, not at submit time. + */ +@Injectable() +export class RateChangeRequestsService { + private readonly logger = new Logger(RateChangeRequestsService.name); + + constructor( + @InjectRepository(RateChangeRequest) + private readonly repo: Repository, + private readonly rates: RatesService, + private readonly inbox: NotificationInboxService, + ) {} + + /** + * File an edit against a LIVE rate. Validated up front so the requester + * hears about a bad unit or a pattern clash immediately rather than the + * approver hitting it days later. + */ + async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { + const rate = await this.rates.findById(dto.rateId); + if (rate.status !== 'LIVE') { + throw new BadRequestException( + `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, + ); + } + + const payload = this.changedFieldsOnly(rate, dto.update); + if (Object.keys(payload).length === 0) { + throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); + } + + // One pending edit per rate: two racing requests would both validate, then + // the second would silently overwrite the first on approval. + const inFlight = await this.repo.findOne({ + where: { rateId: dto.rateId, status: 'PENDING' }, + }); + if (inFlight) { + throw new ConflictException( + 'This rate already has a change awaiting approval. Have it approved or rejected first.', + ); + } + + await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + + const request = await this.repo.save( + this.repo.create({ + rateId: dto.rateId, + payload, + previousValues: this.snapshot(rate, payload), + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Rate change submitted', + `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, + request, + ); + return request; + } + + async list(status?: RateChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { rate: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Approve and apply. The live mutation runs FIRST — if it now fails (someone + * created a clashing rate since submit), the request stays PENDING and the + * approver sees the real error instead of a request marked approved that + * never landed. + */ + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + canSelfApprove = false, + ): Promise { + const request = await this.findPending(id); + + // Separation of duties: the requester cannot approve their own repricing — + // except super admins, who have full backoffice authority. + if (!canSelfApprove && userId && userId === request.requestedByUserId) { + throw new ForbiddenException('You cannot approve a rate change you submitted'); + } + + await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change approved', + `The rate change was approved and is now live.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** Reject — the live rate is never touched, so it simply keeps its value. */ + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Rate change rejected', + `The rate change was rejected — the rate keeps its current value.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Keep only fields the requester actually changed. A form posts every field + * back, so without this the diff would list untouched values as changes. + */ + private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + const patch: Record = {}; + for (const field of DIFFABLE_FIELDS) { + const proposed = (update as Record)[field]; + if (proposed === undefined) continue; + if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; + patch[field] = proposed; + } + return patch; + } + + /** The live values the patch would overwrite — the "before" side of the diff. */ + private snapshot(rate: Rate, payload: Record): Record { + const before: Record = {}; + for (const field of Object.keys(payload)) { + before[field] = (rate as unknown as Record)[field] ?? null; + } + return before; + } + + /** + * rateValue arrives as a string from Postgres `numeric` but as a number from + * the form, so 100 and "100.0000" must compare equal or every submit would + * look like a change. + */ + private sameValue(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null && b == null) return true; + if (a == null || b == null) return false; + const numA = Number(a); + const numB = Number(b); + if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { + return numA === numB; + } + return String(a) === String(b); + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); + if (!request) throw new NotFoundException(`Rate change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); + } + return request; + } + + /** Fire-and-forget — a notification failure never blocks the workflow. */ + private notifyTeam(title: string, body: string, request: RateChangeRequest): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RATES_LINK, + data: { rateChangeRequestId: request.id, rateId: request.rateId }, + }) + .catch((err) => + this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 3cd17cf6e..b9a5e7873 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -119,12 +119,62 @@ export class RatesService { }); } - /** Update a DRAFT rate. */ + /** + * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit + * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. + */ async update(id: string, dto: UpdateRateDto): Promise { const existing = await this.findById(id); if (existing.status !== 'DRAFT') { - throw new BadRequestException('Only DRAFT rates can be updated'); + throw new BadRequestException( + existing.status === 'LIVE' + ? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.' + : 'Only DRAFT rates can be updated', + ); } + return this.applyUpdate(existing, dto); + } + + /** + * Apply an approved change request to a LIVE rate. Same validation as a + * DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching + * here has already been through approval. Only ever called by + * RateChangeRequestsService.approve. + */ + async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'LIVE') { + throw new BadRequestException( + `Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`, + ); + } + return this.applyUpdate(existing, dto); + } + + /** + * Validate a proposed patch against a rate without writing anything — lets a + * change request be refused at submit time instead of surprising the + * approver. Throws exactly what applying it would throw. + */ + async assertUpdateValid(id: string, dto: UpdateRateDto): Promise { + await this.buildUpdate(await this.findById(id), dto); + } + + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { + const updates = await this.buildUpdate(existing, dto); + const updated = await this.repository.update(existing.id, updates); + if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`); + return updated; + } + + /** + * The shared edit body: re-derives rateType, re-validates the unit against + * the (possibly changed) shape, and guards pattern uniqueness. Status is + * never touched — an approved edit to a LIVE rate stays LIVE. Pure apart + * from the uniqueness read, so it doubles as the dry-run validator. + */ + private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise> { + const id = existing.id; const updates: Partial = {}; const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; @@ -179,9 +229,7 @@ export class RatesService { updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - const updated = await this.repository.update(id, updates); - if (!updated) throw new NotFoundException(`Rate ${id} not found`); - return updated; + return updates; } /** Submit a DRAFT rate for CEO approval. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 2948b874d..c5e6fa65f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -3,20 +3,6 @@ import { Type } from 'class-transformer'; import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { - @ApiPropertyOptional({ example: 760 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainLengthMeters?: number; - - @ApiPropertyOptional({ example: 3500 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - maxTrainWeightTons?: number; - @ApiPropertyOptional({ example: 53 }) @IsOptional() @Type(() => Number) @@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) maxWagonsPerTrain?: number; - @ApiPropertyOptional({ example: 30 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0.001) - max20ftContainerWeightTons?: number; - - @ApiPropertyOptional({ example: 10 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(0) - max20ftPairWeightDiffTons?: number; - @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) @IsOptional() @Type(() => Number) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index a08b12378..0d9bf63c9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -600,7 +600,24 @@ export class TrainSchedulingService { } async getTrainSchedulingGlobalRules() { - return this.loadGlobalRulesRow(); + return this.toPublicGlobalRules(await this.loadGlobalRulesRow()); + } + + /** + * Train length/weight and 20ft weight caps are engine-internal (wagon + * planning still reads them off the row); they are no longer exposed or + * editable through the global-rules endpoints. + */ + private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { + if (!row) return row; + const { + maxTrainLengthMeters: _len, + maxTrainWeightTons: _wt, + max20ftContainerWeightTons: _cw, + max20ftPairWeightDiffTons: _pd, + ...pub + } = row; + return pub; } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { @@ -608,15 +625,7 @@ export class TrainSchedulingService { if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } - if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; - if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; - if (dto.max20ftContainerWeightTons != null) { - row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; - } - if (dto.max20ftPairWeightDiffTons != null) { - row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; - } if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; @@ -654,7 +663,7 @@ export class TrainSchedulingService { await this.restampPendingWindows(); } - return saved; + return this.toPublicGlobalRules(saved); } /** diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6e4ec81b1..d3b5bbb00 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record> = { + rates: 'b2000001-0001-4000-8000-000000000017', +}; + +export type RuleEngineApprovableSlug = 'rates'; + export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ...(approveId + ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] + : []), ]; }, ); @@ -389,6 +404,8 @@ export const FREIGHT_PERMS = { `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + approve: (slug: RuleEngineApprovableSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, allocation: { manage: 'edr_freight_app:allocation:manage', diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 164094cd3..160e0a61d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -167,6 +167,7 @@ export const QUERY_KEYS = { orderList: (resource: RuleEngineResourceSlug | string) => ["rule-engine", "order-list", resource] as const, priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const, + rateChanges: ["rule-engine", "rate-changes"] as const, }, OVERVIEW: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 5ae7bac72..d0b34cb5b 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -7,6 +7,7 @@ import { ruleEngineService, type RuleEngineListParams, type SubmitPriorityRuleChangePayload, + type SubmitRateChangePayload, } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { @@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = ( return { pending, submit, approve, reject }; }; +/** + * Approval workflow for edits to LIVE rates. The live rate keeps its current + * value until a change is approved, so the rates list is invalidated on every + * outcome — including reject, which restores the row's "no pending" state. + */ +export const useRateChangeWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { + const qc = useQueryClient(); + + const showError = (err: unknown, fallback: string) => { + const raw = (err as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback; + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + + const pending = useQuery({ + queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges, + queryFn: () => ruleEngineService.listRateChanges("PENDING"), + enabled, + }); + + const invalidate = async () => { + await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges }); + await invalidateRuleEngineList(qc, "rates"); + }; + + const submit = useMutation({ + mutationFn: (payload: SubmitRateChangePayload) => + ruleEngineService.submitRateChange(payload), + onSuccess: async () => { + toast.success( + "Change submitted for approval — the rate keeps its current value until approved", + ); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to submit rate change"), + }); + + const approve = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.approveRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change approved — the new rate is now live"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to approve rate change"), + }); + + const reject = useMutation({ + mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) => + ruleEngineService.rejectRateChange(id, decisionNote), + onSuccess: async () => { + toast.success("Rate change rejected — the rate keeps its current value"); + await invalidate(); + }, + onError: (err) => showError(err, "Failed to reject rate change"), + }); + + return { pending, submit, approve, reject }; +}; + export const useRateWorkflow = () => { const qc = useQueryClient(); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 9bf9aab6e..98f998bc0 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string { return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`; } +/** + * Deciding a filed change — a step above `manage`, which only lets a staff + * member propose one. Only resources with an approval workflow have it. + */ +export function ruleEngineApproveKey(slug: "rates"): string { + return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`; +} + +export function canApproveRuleEngineChange( + user: AuthUser | null | undefined, + slug: "rates", +): boolean { + return hasPermission(user, ruleEngineApproveKey(slug)); +} + export function canAccessRuleEngineResource( user: AuthUser | null | undefined, slug: RuleEngineResourceSlug, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx new file mode 100644 index 000000000..6156762a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -0,0 +1,247 @@ +import { useState } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Group, + Stack, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import type { UseMutationResult } from "@tanstack/react-query"; +import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react"; + +import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service"; + +/** Field labels for the diff — anything not listed falls back to the raw key. */ +const FIELD_LABELS: Record = { + rateValue: "Rate", + currency: "Currency", + rateUnit: "Unit", + appliesTo: "Applies to", + trigger: "Trigger", + tradeDirection: "Direction", + containerTypeId: "Container type", + cargoTypeId: "Cargo type", +}; + +const fmtDateTime = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +const fmtValue = (field: string, value: unknown): string => { + if (value === null || value === undefined || value === "") return "—"; + if (field === "rateValue") { + const num = Number(value); + return Number.isNaN(num) ? String(value) : num.toLocaleString(); + } + return String(value).replace(/_/g, " "); +}; + +/** "Ocean freight · 40HC" — what rate this change targets. */ +const rateSummary = (r: RateChangeRequest): string => { + const rate = (r.rate ?? {}) as Record; + const parts = [ + rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null, + rate.appliesTo ? String(rate.appliesTo) : null, + rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null, + ].filter(Boolean); + return parts.join(" · ") || "Rate"; +}; + +/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ +const headline = (r: RateChangeRequest): string | null => { + if (!("rateValue" in r.payload)) return null; + const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); + const before = fmtValue("rateValue", r.previousValues.rateValue); + const after = fmtValue("rateValue", r.payload.rateValue); + return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +}; + +type Decide = UseMutationResult< + RateChangeRequest, + unknown, + { id: string; decisionNote?: string } +>; + +interface RateApprovalsSectionProps { + requests: RateChangeRequest[]; + /** Whether this user holds the rates approve permission. */ + canDecide: boolean; + approve: Decide; + reject: Decide; +} + +/** + * Pending edits to LIVE rates. Each row is a before→after diff: the left value + * is what pricing charges right now and keeps charging until someone approves. + * Rendered above the rates table. + */ +const RateApprovalsSection = ({ + requests, + canDecide, + approve, + reject, +}: RateApprovalsSectionProps) => { + const [openId, setOpenId] = useState(null); + const [notes, setNotes] = useState>({}); + + if (requests.length === 0) return null; + + const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; + + return ( + + + + Pending rate changes + + {requests.length} + + + + Each rate below still charges its current value. Nothing changes until approved. + + + + {requests.map((r) => { + const isOpen = openId === r.id; + const fields = Object.keys(r.payload); + const summaryLine = headline(r); + // Only the row being decided shows a spinner — the mutation's + // isPending is shared across every row. + const busy = decidingId === r.id; + + return ( + + + + + + update + + + {rateSummary(r)} + + + + {summaryLine ? ( + + + {fmtValue("rateValue", r.previousValues.rateValue)} + + + + {fmtValue("rateValue", r.payload.rateValue)} + + + {String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + )} + + + ) : null} + + + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + + + + + + {canDecide ? ( + + + + + ) : ( + + + Awaiting approver + + + )} + + + + + {fields.map((field) => ( + + + {FIELD_LABELS[field] ?? field} + + + {fmtValue(field, r.previousValues[field])} + + + + {fmtValue(field, r.payload[field])} + + + ))} + {canDecide ? ( +