From 5a1e0dba4ddc063403d087fb59db12c0f3bb81a1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 07:43:17 +0000 Subject: [PATCH 01/36] Comment out payment event handling for local demos in BillingService --- .../src/modules/billing/billing.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..774234000 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1034,16 +1034,16 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // if (!result.immediateSuccess) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( From e115b9b7533712a07c70a16b79039b2e467c84b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 12:43:54 +0000 Subject: [PATCH 02/36] fix(warehouse): resolve Load-to-Train bookings from wagon allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Load-to-Train queue was permanently empty for real traffic. loadableTrains() and trainLoadableItems() gated on freight.train_schedule_bookings, but nothing in the application writes that table — only the demo seeders do. Real wagon allocation writes wagon_booking_allocations, reached via train_schedules -> train_sets -> train_set_wagons, so an allocated export booking never satisfied the EXISTS gate and no train ever appeared. Both queries now resolve a schedule's bookings through a shared sched_bookings CTE that unions the wagon-allocation chain with train_schedule_bookings, so real allocations show up and the seeded demo scenarios keep working. The panel already groups the returned rows by booking with their containers, so the queue now lists the train, its bookings and their containers for selection. Export flow this serves: booked -> paid -> received at the warehouse (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the booking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 07c720cff..70abf635b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1542,11 +1542,38 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * The bookings riding a train schedule. + * + * Export flow: booked -> paid -> received at the warehouse (first-mile or + * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking + * actually reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only + * ever written by the demo seeders — keying off it alone left this queue + * permanently empty for real traffic — so both sources are unioned. + */ + private readonly SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1554,15 +1581,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1570,10 +1597,10 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, @@ -1599,7 +1626,8 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", @@ -1612,9 +1640,9 @@ export class WarehouseInventoryService { wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1631,7 +1659,7 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + WHERE sb.schedule_id = $1 AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], From 2798fed6d8bd488c67e4f2446ed16178db6f4a2f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:09:20 +0000 Subject: [PATCH 03/36] fix(train-scheduling): block dispatch when allocated cargo is not loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatchSchedule guarded status, Djibouti departure rules, locomotives and wagons — but never checked the cargo. A train could be dispatched while the bookings allocated to it sat received in the warehouse, silently leaving them behind. Dispatch now refuses when an allocated booking has warehouse inventory in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the two ways out: load them, or drop the wagon allocation so they ride a later train. Bookings with no inventory at all are not blocked — allocating a wagon before the goods arrive is normal planning. Also drops RESERVED from the Load-to-Train filters: reserved stock is not awaiting loading. The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse loading queue and this dispatch guard resolve a train's bookings identically — if they drift, a train departs leaving cargo the warehouse still expects to load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/common/schedule-bookings.sql.ts | 28 +++++++++++++++ .../train-scheduling.service.ts | 34 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 34 +++++-------------- 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/common/schedule-bookings.sql.ts diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b92a7ea90..97984be5f 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 @@ -19,6 +19,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2029,6 +2030,37 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * A train must not leave carrying nothing while its cargo sits in the shed. + * Blocks dispatch when a booking allocated to this train has warehouse + * inventory that never made it onto a wagon (received / stored / ready but not + * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the + * booking's wagon allocation so it travels on a later train. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2038,6 +2070,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Don't leave received cargo behind on the platform. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 70abf635b..3ad71b6fc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -1543,30 +1544,11 @@ export class WarehouseInventoryService { /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ /** - * The bookings riding a train schedule. - * - * Export flow: booked -> paid -> received at the warehouse (first-mile or - * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking - * actually reaches a train through WAGON ALLOCATION - * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), - * which is what the allocation UI writes. `train_schedule_bookings` is only - * ever written by the demo seeders — keying off it alone left this queue - * permanently empty for real traffic — so both sources are unioned. + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. */ - private readonly SCHEDULE_BOOKINGS_CTE = ` - sched_bookings AS ( - SELECT ts.id AS schedule_id, wba.booking_id - FROM freight.train_schedules ts - JOIN freight.train_set_wagons tsw - ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL - JOIN freight.wagon_booking_allocations wba - ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL - WHERE ts.deleted_at IS NULL - UNION - SELECT tsb.train_schedule_id, tsb.booking_id - FROM freight.train_schedule_bookings tsb - WHERE tsb.deleted_at IS NULL - )`; + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; async loadableTrains(): Promise { const rows: Array< @@ -1585,7 +1567,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL WHERE sb.schedule_id = ts.id - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1601,7 +1583,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv2 ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL WHERE sb2.schedule_id = ts.id - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1660,7 +1642,7 @@ export class WarehouseInventoryService { LIMIT 1 ) wl ON true WHERE sb.schedule_id = $1 - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); From d9db4cbc0cd663683acb13aa83510efd102f12bf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:21:55 +0000 Subject: [PATCH 04/36] fix(train-scheduling): scope the not-loaded dispatch guard to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard fired on every dispatch. Loading out of an origin warehouse is an export concept — import cargo isn't loaded from a warehouse, so its warehouse inventory says nothing about what's aboard and the check would have blocked legitimate import dispatches. Derive the route direction (reusing deriveTradeDirection, as the warehouse loading queue does) and return early for anything that isn't EXPORT. Import and domestic behave exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../train-scheduling.service.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) 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 97984be5f..bb85b4422 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 @@ -2031,16 +2031,37 @@ export class TrainSchedulingService { } /** - * A train must not leave carrying nothing while its cargo sits in the shed. - * Blocks dispatch when a booking allocated to this train has warehouse - * inventory that never made it onto a wagon (received / stored / ready but not - * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the - * booking's wagon allocation so it travels on a later train. + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. * * Bookings with no warehouse inventory at all are NOT blocked — allocating a * wagon before the goods arrive is normal planning; they simply aren't aboard. */ private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT DISTINCT b.reference AS "reference", inv.status AS "status" @@ -2070,7 +2091,7 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Don't leave received cargo behind on the platform. + // Export only: don't leave received cargo behind in the warehouse. await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. From a26fa61d6685fd867a72766601fb036847a52522 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:32:38 +0000 Subject: [PATCH 05/36] feat(warehouse): require a GRN before export cargo can be loaded onto a train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export chain is: paid -> received into the warehouse -> GRN raised on arrival -> loaded onto the allocated wagon. Receipt was already structural (the inventory row only exists once receive() runs) and the wagon was already required, but the GRN was merely displayed, never enforced — so cargo could be loaded and dispatched without one. - loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo. - loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and a hand-made API call can't bypass it. - Read the GRN from inv.grn_number (what receive() stamps) and fall back to the note only for legacy/seeded rows; it previously read the note alone, which the real receive path merely mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3ad71b6fc..19b1c0159 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1616,7 +1616,12 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", @@ -1649,7 +1654,11 @@ export class WarehouseInventoryService { return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1702,6 +1711,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { From a95667fde45c3805ecb4179783389fac38bd9b3e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:44:02 +0000 Subject: [PATCH 06/36] fix(warehouse): raise a GRN on every warehouse receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requiring a GRN before loading only works if every path into the warehouse issues one. Two did not: autoUnloadArrived and unloadBooking created RECEIVED inventory with a null grn_number, so cargo that genuinely arrived — by first mile or self haul — would have been stuck un-loadable behind the new gate. Both now stamp a GRN, derived from the booking's trade direction, matching receive/bulkReceive/autoUnloadArrivedBookings. unloadBooking keeps an already-issued GRN when it re-unloads an existing row rather than reissuing one. Every path that creates warehouse inventory now issues a GRN, so the chain is seamless: booking arrives (first mile or self haul) -> received -> GRN -> loadable onto its allocated wagon. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 19b1c0159..3630aa9de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1032,6 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // Goods reaching the warehouse always get a GRN, whichever path brought + // them in — nothing loads onto a train without one. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1041,6 +1043,11 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + grnNumber: this.generateGrnNumber( + booking.tradeDirection ?? 'WH', + booking.id, + new Date(), + ), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1061,6 +1068,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + // Goods reaching the warehouse always get a GRN, whichever path brought them + // in — nothing loads onto a train without one. + const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1081,6 +1096,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, + // Keep an already-issued GRN; only raise one if this row never got it. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1095,6 +1114,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From fc636fbe4f77945c0ae3368041e789f8ce5a61b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:45:50 +0000 Subject: [PATCH 07/36] fix(warehouse): scope the arrival GRN stamp to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made autoUnloadArrived and unloadBooking raise a GRN for any direction, which changed import behaviour. Import keeps its own GRN handling (autoUnloadArrivedBookings) and is left exactly as it was. Both paths now stamp a GRN only when the booking is EXPORT — the direction whose cargo needs one to be loaded onto a train. Import and domestic behave as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3630aa9de..c489b6f89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1032,8 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } - // Goods reaching the warehouse always get a GRN, whichever path brought - // them in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1043,11 +1043,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), - grnNumber: this.generateGrnNumber( - booking.tradeDirection ?? 'WH', - booking.id, - new Date(), - ), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1068,14 +1066,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // Goods reaching the warehouse always get a GRN, whichever path brought them - // in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto + // a train without one. Import GRN handling is left untouched. const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection" FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); - const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const isExport = bookingRow?.tradeDirection === 'EXPORT'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1096,10 +1094,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Keep an already-issued GRN; only raise one if this row never got it. - ...(existing[0].grnNumber - ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + // Export only, and keep an already-issued GRN rather than reissuing. + ...(isExport && !existing[0].grnNumber + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1114,7 +1112,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From bd81bbfc2c4baff04a81194c652189b219f80099 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 17 Jul 2026 11:15:58 +0300 Subject: [PATCH 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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 14/36] 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 15/36] 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 16/36] 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 ? ( +