From 1fe8c2fd8e0da1f0c3f9aaa5979c859d6d79d179 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 08:00:56 +0000 Subject: [PATCH 01/38] feat(export): gate receive on payment and loading on received + GRN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three export rules that the flow left open. An unpaid export booking could be received at the warehouse. Receiving is what starts storage and mints a GRN, so it must not happen against cargo the customer has not settled. receive() now rejects an unpaid EXPORT booking. Import is untouched — it arrives OFF a train and its receive is the unload, so gating that on payment would strand cargo already at the yard. An allocated export booking could be marked loaded onto its train without ever reaching the warehouse. An allocation is a plan; the GRN is the proof the goods are in hand. Two loading paths skipped that check — the per-yard loadBooking and the workspace confirmScheduleLoading — and both now require every export booking to be received with a GRN first, however it arrived (first-mile or the customer's own truck) and whatever it is allocated to. The rule lives in one shared guard (assertExportReceivedWithGrn) so the two paths cannot drift. Export self-haul without a first-mile leg already worked and is unchanged: assertSelfHaulPaid allows a customer truck when there is no EDR mile leg and the booking is paid, and addTruck applies the same one-40ft-or-two-20ft rule to containers and the tonnage drawdown to bulk, exactly as import does. Co-Authored-By: Claude Opus 4.8 --- .../src/common/export-received-gate.spec.ts | 39 +++++++++++++ .../src/common/export-received-gate.ts | 50 +++++++++++++++++ .../booking-journey.service.ts | 4 ++ .../train-scheduling.service.ts | 37 +++++++++++++ .../warehouses/receive-export-paid.spec.ts | 55 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 26 +++++++++ 6 files changed, 211 insertions(+) create mode 100644 apps/edr-freight-api/src/common/export-received-gate.spec.ts create mode 100644 apps/edr-freight-api/src/common/export-received-gate.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index fb140158c..db18d6804 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -68,6 +69,9 @@ export class BookingJourneyService { } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); + // Export cargo must be in the warehouse with a GRN before it can be loaded, + // however it arrived and whatever it is allocated to. + await assertExportReceivedWithGrn(this.dataSource, booking); const now = new Date(); await this.dataSource.transaction(async (manager) => { 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 2f7ca0776..93d8bba56 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 @@ -2632,6 +2632,11 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Export cargo must be received at the warehouse with a GRN before it can + // be confirmed loaded — an allocation is not proof the goods are in hand. + if (this.isExportSchedule(schedule)) { + await this.assertExportBookingsReceived([...wagonAssignedIds]); + } await this.trainScheduleBookingsRepository.updateLoadingStatusMany( scheduleId, [...wagonAssignedIds], @@ -2909,6 +2914,38 @@ export class TrainSchedulingService { return direction === 'EXPORT'; } + /** + * Every export booking being confirmed loaded must already be received at the + * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this + * is the check that the cargo is physically in the yard before we call it loaded. + */ + private async assertExportBookingsReceived(bookingIds: string[]): Promise { + if (!bookingIds.length) return; + const rows: Array<{ reference: string | null }> = await this.dataSource.query( + `SELECT b.reference + FROM freight.bookings b + WHERE b.id = ANY($1) + AND b.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + )`, + [bookingIds], + ); + if (rows.length) { + const refs = rows.map((r) => r.reference ?? '(unknown)').join(', '); + throw new BadRequestException( + `These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`, + ); + } + } + private buildImportLoadListHtml(loadList: Awaited>): string { const esc = (value: unknown) => String(value ?? '-') diff --git a/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts new file mode 100644 index 000000000..38268af11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export cargo is received into the warehouse to wait for its train, and only a + * paid booking may be received — otherwise storage and a GRN would start against + * cargo the customer has not settled. Import is never blocked: it arrives OFF a + * train and its receive is the unload. + * + * The guard touches only the DataSource, so the instance is built off the + * prototype rather than stubbing all 20-odd collaborators. + */ +type Guard = ( + bookingId: string | null | undefined, + direction: string | null, +) => Promise; + +function makeGuard(paymentStatus: string | null) { + const query = jest.fn().mockResolvedValue([{ paymentStatus }]); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const guard = ( + service as unknown as { assertExportBookingPaid: Guard } + ).assertExportBookingPaid.bind(service); + return { guard, query }; +} + +describe('receive() — export paid gate', () => { + it('rejects an unpaid export booking', async () => { + const { guard } = makeGuard('PENDING'); + + await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows a paid export booking', async () => { + const { guard } = makeGuard('PAID'); + + await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined(); + }); + + it('never blocks import, paid or not', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); + + it('ignores a receive with no booking attached', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard(null, 'EXPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); +}); 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 d05007d8c..d842727ee 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 @@ -2550,6 +2550,7 @@ export class WarehouseInventoryService { async receive(dto: ReceiveWarehouseInventoryDto): Promise { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; + await this.assertExportBookingPaid(dto.bookingId, bookingDirection); const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -5648,6 +5649,31 @@ export class WarehouseInventoryService { ); } + /** + * Export cargo is received into the warehouse to wait for its train, and it is + * received only once the booking is paid — receiving an unpaid export booking + * would start storage and mint a GRN against cargo the customer has not settled. + * + * Export only: import cargo arrives OFF a train and its receive is the unload, + * so gating that on payment would strand cargo already at the yard. + */ + private async assertExportBookingPaid( + bookingId: string | null | undefined, + direction: string | null, + ): Promise { + if (!bookingId || direction !== 'EXPORT') return; + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') { + throw new BadRequestException( + 'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.', + ); + } + } + private assertCapacity( label: string, node: LocationNode, From 2397c6bca4c26dbb562273aa92eb928ee0e6d426 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 08:19:45 +0000 Subject: [PATCH 02/38] fix(warehouse): show assigned trucks on the Trucks on Site page, not only arrived ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page filtered on arrived_at IS NOT NULL, so a truck appeared only once the warehouse receive flow stamped its arrival. Assigned trucks that had not yet reached the yard were invisible, which left the page empty whenever nothing had been received — every assigned truck was missing. It now lists every truck assigned to a booking that has not departed, from both haulage paths, tagged INBOUND (assigned, not yet arrived) or ON_SITE (arrived). A scope toggle filters between them, dwell time shows only once a truck has actually arrived, and the KPI count on the dashboard stays strict (arrived only). Co-Authored-By: Claude Opus 4.8 --- .../warehouses/warehouse-inventory.service.ts | 14 +++-- .../src/pages/warehouses/TrucksOnSitePage.tsx | 63 ++++++++++++++----- .../backoffice/src/types/warehouse.ts | 2 + 3 files changed, 59 insertions(+), 20 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 d842727ee..96122d9bd 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 @@ -417,12 +417,16 @@ export class WarehouseInventoryService { * * Covers both haulage paths because the gate does: a customer's own truck and * an EDR last-mile truck arrive at the same barrier and need the same paper. - * "On site" means arrived and not yet departed. + * Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see + * what is coming as well as what is here — an assigned truck only stamps + * `arrived_at` when it reaches the warehouse. A truck drops off the list once + * it departs. */ async trucksOnSite(): Promise< Array<{ source: 'CUSTOMER' | 'EDR'; assignmentId: string; + status: 'INBOUND' | 'ON_SITE'; plateNumber: string | null; driverName: string | null; truckType: string | null; @@ -436,6 +440,7 @@ export class WarehouseInventoryService { return this.dataSource.query( `SELECT 'CUSTOMER' AS "source", a.id AS "assignmentId", + CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", @@ -450,13 +455,13 @@ export class WarehouseInventoryService { JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id WHERE a.deleted_at IS NULL - AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL UNION ALL SELECT 'EDR' AS "source", va.id AS "assignmentId", + CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber", NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName", v.vehicle_type AS "truckType", @@ -474,10 +479,11 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id LEFT JOIN freight.companies company ON company.id = b.company_id WHERE va.deleted_at IS NULL - AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL - ORDER BY "arrivedAt" ASC`, + -- On-site trucks first, each group oldest-arrival first; inbound trucks + -- (null arrival) sort to the end. + ORDER BY "arrivedAt" ASC NULLS LAST`, ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx index 9d2ae0e63..2c1bc714b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx @@ -47,15 +47,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) { if (rows.length === 0) { return ( - No trucks on site. + No trucks assigned or on site. ); } return ( - + + Status Plate Haulage Driver @@ -69,6 +70,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) { {rows.map((row) => ( + + + {row.status === "ON_SITE" ? "On site" : "Inbound"} + + {row.plateNumber ?? "—"} @@ -101,9 +112,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) { {row.containers ?? "Bulk"} - {isLongDwell(row.arrivedAt) ? ( + {row.arrivedAt == null ? ( + + — + + ) : isLongDwell(row.arrivedAt) ? ( @@ -124,12 +139,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) { export default function TrucksOnSitePage() { const { data: trucks = [], isLoading } = useTrucksOnSite(); + const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">("ALL"); const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL"); const [search, setSearch] = useState(""); const rows = useMemo(() => { const term = search.trim().toLowerCase(); return trucks + .filter((t) => scope === "ALL" || t.status === scope) .filter((t) => source === "ALL" || t.source === source) .filter((t) => !term @@ -137,8 +154,10 @@ export default function TrucksOnSitePage() { : [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers] .some((field) => field?.toLowerCase().includes(term)), ); - }, [trucks, source, search]); + }, [trucks, scope, source, search]); + const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length; + const inboundCount = trucks.length - onSiteCount; const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length; const edrCount = trucks.length - customerCount; @@ -146,20 +165,32 @@ export default function TrucksOnSitePage() { - setSource(v as typeof source)} - data={[ - { label: `All (${trucks.length})`, value: "ALL" }, - { label: `Customer (${customerCount})`, value: "CUSTOMER" }, - { label: `EDR (${edrCount})`, value: "EDR" }, - ]} - /> + + setScope(v as typeof scope)} + data={[ + { label: `All (${trucks.length})`, value: "ALL" }, + { label: `On site (${onSiteCount})`, value: "ON_SITE" }, + { label: `Inbound (${inboundCount})`, value: "INBOUND" }, + ]} + /> + setSource(v as typeof source)} + data={[ + { label: "All", value: "ALL" }, + { label: `Customer (${customerCount})`, value: "CUSTOMER" }, + { label: `EDR (${edrCount})`, value: "EDR" }, + ]} + /> + Date: Tue, 21 Jul 2026 08:30:52 +0000 Subject: [PATCH 03/38] Enable source maps in Node command for better stack trace debugging --- apps/edr-passenger-api/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 45b365496..bd06feb8b 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -57,4 +57,7 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 4000 -CMD ["node", "dist/main.js"] +# --enable-source-maps: translate stack-trace frames from dist/*.js back to +# src/*.ts using the .js.map files nest build emits (tsconfig sourceMap:true). +# Without it Node reports compiled JS line numbers, not TypeScript source. +CMD ["node", "--enable-source-maps", "dist/main.js"] From 603537a20b65cd45e72c5c88585a7a1c6d519dcc Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 21 Jul 2026 08:50:50 +0000 Subject: [PATCH 04/38] add yard distances management to rule engine - Introduced new yard distances resource with CRUD operations. - Created migration for yard distances table with necessary constraints. - Implemented service and repository for yard distances handling. - Added controller for API endpoints to manage yard distances. - Updated rule engine configuration to include yard distances. - Enhanced rule engine resource page to support yard distance selection. - Updated contracts and train builder pages to handle new yard distance logic. - Added error handling utility for better error message extraction. --- .../2060000000000-CreateYardDistances.ts | 66 +++++++++ .../contracts/contract-notifier.service.ts | 13 ++ .../contracts/contract-transition.service.ts | 93 ++++++++++++- .../modules/contracts/contracts.controller.ts | 6 +- .../modules/contracts/contracts.repository.ts | 19 +++ .../modules/contracts/dto/approve-step.dto.ts | 18 ++- .../locomotives/dto/filter-locomotives.dto.ts | 28 +++- .../locomotives/locomotives.repository.ts | 44 +++++- .../locomotives/locomotives.service.ts | 11 ++ .../modules/routes/dto/create-route.dto.ts | 13 +- .../src/modules/routes/routes.service.ts | 79 +++++++---- .../controllers/yard-distances.controller.ts | 62 +++++++++ .../dto/create-yard-distance.dto.ts | 22 +++ .../dto/list-rule-engine-query.dto.ts | 12 ++ .../dto/update-yard-distance.dto.ts | 5 + .../entities/yard-distance.entity.ts | 35 +++++ .../yard-distances.repository.interface.ts | 17 +++ .../repositories/yard-distances.repository.ts | 87 ++++++++++++ .../modules/rule-engine/rule-engine.module.ts | 12 ++ .../services/yard-distances.service.ts | 119 +++++++++++++++++ .../modules/trains/train-builder.service.ts | 24 +++- .../src/seed/freight-permissions.registry.ts | 2 + .../backoffice/src/auth/http.ts | 15 ++- .../contracts/ContractApprovalStepsCard.tsx | 95 ++++++++++--- .../trainBuilder/BuildTrainModal.tsx | 12 +- .../trainBuilder/ChangeLocomotivesModal.tsx | 12 +- .../components/trainBuilder/trainStatus.ts | 28 ++++ .../backoffice/src/constants/URLS.ts | 3 + .../src/hooks/bookings/useBookings.ts | 30 ++--- .../src/hooks/contracts/useContracts.ts | 59 +++++--- .../src/hooks/rule-engine/useRuleEngine.ts | 16 ++- .../backoffice/src/lib/queryClient.ts | 18 +++ .../backoffice/src/pages/fleet/RoutesPage.tsx | 126 ++++++++++++------ .../ruleEngine/RuleEngineResourcePage.tsx | 22 ++- .../src/pages/ruleEngine/config/resources.ts | 29 ++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 60 +++++++++ .../src/services/contracts.service.ts | 13 +- .../src/services/locomotives.service.ts | 6 + .../backoffice/src/services/routes.service.ts | 3 +- .../services/ruleEngine/ruleEngine.service.ts | 3 + .../backoffice/src/types/rule-engine/index.ts | 1 + .../backoffice/src/utils/errorExtractor.ts | 17 +++ .../src/pages/contracts/NewContractPage.tsx | 23 ++-- .../new-contract-form/step2-service-type.tsx | 22 +-- .../new-contract-form/step8-review.tsx | 102 +++++++------- 45 files changed, 1275 insertions(+), 227 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/errorExtractor.ts diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index e35bd2bf5..ac4fe2a0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -143,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 2fd1e4cb4..107f2ab33 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -41,6 +41,7 @@ import { ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ @@ -550,17 +551,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -568,6 +576,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -590,6 +612,67 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: no clearance-fee expiry (the contract + * is still alive) and no customer-facing REJECTION note — the trail is a + * staff note plus a backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index ca4814081..8b7a62536 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -441,7 +441,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -453,6 +456,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 533b51b3b..e4d5810ea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -368,6 +368,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index c634d5efb..a42dcd220 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; import { LOCOMOTIVE_STATUSES, @@ -21,4 +22,29 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() currentYardId?: string; + + /** + * Drop locomotives already coupled to a built train — the train-builder + * "change locomotives" picker uses this so a loco that belongs to another + * train is never offered (the backend would 409 on save anyway). Combine with + * `excludeTrainId` to keep the CURRENT train's own locos in the list. + */ + @ApiPropertyOptional({ + description: 'Exclude locomotives already coupled to any built train', + }) + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + excludeCoupled?: boolean; + + /** + * When `excludeCoupled` is set, locos coupled to THIS train are still kept + * (they are valid picks — you are editing that train's consist). + */ + @ApiPropertyOptional({ + description: 'Train id whose own coupled locomotives are NOT excluded', + }) + @IsOptional() + @IsUUID() + excludeTrainId?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 18a42205e..3ad5b3650 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Locomotive } from './entities/locomotive.entity'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; +import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @Injectable() export class LocomotivesRepository extends BaseRepository { @@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository { super(repository); } + /** + * List locomotives for the train-builder coupling picker: the usual + * status/type/yard filters, plus optional exclusion of any loco already + * coupled to a built train. `keepTrainId` spares that one train's own locos + * from the exclusion so they stay selectable while editing its consist. + */ + findForCoupling(opts: { + status?: LocomotiveStatus; + locomotiveType?: LocomotiveType; + currentYardId?: string; + excludeCoupled?: boolean; + keepTrainId?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .leftJoinAndSelect('locomotive.currentYard', 'currentYard') + .orderBy('locomotive.code', 'ASC'); + + if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status }); + if (opts.locomotiveType) + qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType }); + if (opts.currentYardId) + qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + + if (opts.excludeCoupled) { + // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the + // consist being edited still lists its current locomotives. + const sub = this.repository.manager + .getRepository(TrainLocomotive) + .createQueryBuilder('tl') + .select('1') + .where('tl.locomotiveId = locomotive.id'); + if (opts.keepTrainId) { + sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId }); + } + qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); + } + + return qb.getMany(); + } + /** * A live locomotive already holding this name, compared the same way the * `UQ_locomotives_name_active` index compares: case- and whitespace- diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index cbf9dfc0c..46e0ae415 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -29,6 +29,17 @@ export class LocomotivesService { } findAll(filter: FilterLocomotivesDto): Promise { + // The coupling picker needs a NOT-EXISTS against the train link table, so it + // takes the query-builder path; the plain list keeps the simple where. + if (filter.excludeCoupled) { + return this.locomotivesRepository.findForCoupling({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: true, + keepTrainId: filter.excludeTrainId, + }); + } return this.locomotivesRepository.findAll({ where: { ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 3f4d2e4ff..3de24835a 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -4,25 +4,22 @@ import { ArrayMinSize, IsArray, IsEnum, - IsNumber, IsOptional, IsUUID, - Min, ValidateNested, } from 'class-validator'; import { RouteStatus } from '../entities/route.entity'; +/** + * Segment distances are no longer part of the payload — they are resolved + * from the configured yard_distances table (Configuration → Yard Distances) + * and snapshotted onto route_milestones at create/update. + */ export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; - - @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) - @IsOptional() - @IsNumber() - @Min(0) - distanceKm?: number; } export class CreateRouteDto { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4b1bfd08a..96e6c7fd1 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity'; import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; +/** Order-insensitive key: distances are symmetric. */ +const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`); + @Injectable() export class RoutesService { constructor( @@ -183,47 +187,63 @@ export class RoutesService { return this.findById(id); } - private async validateMilestones( - milestones: Array<{ yardId: string; distanceKm?: number }>, - ) { + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => { - const distanceKm = - index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; - if (index > 0 && (distanceKm == null || distanceKm < 0)) { - throw new BadRequestException( - `Enter segment KM for stop ${index + 1} (from previous yard).`, - ); - } - return { - yardId: milestone.yardId, - sequenceNo: index + 1, - distanceKm, - }; - }); - - const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))]; const yards = await this.dataSource .getRepository(Yard) .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); - for (const milestone of normalized) { + for (const milestone of milestones) { if (!yardIds.has(milestone.yardId)) { throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); } } - if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + if (milestones[0].yardId === milestones[milestones.length - 1].yardId) { throw new BadRequestException('Origin and destination yards must be different'); } - const originYardId = normalized[0].yardId; - const destinationYardId = normalized[normalized.length - 1].yardId; const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const distanceByPair = await this.loadDistanceLookup(uniqueYardIds); + + // Segment km come from the configured yard-distance table, not the payload + // — a route can only be built over pairs an admin has entered. Distances + // are symmetric, so an A→B row also serves B→A. + const missingPairs: string[] = []; + const normalized = milestones.map((milestone, index) => { + if (index === 0) { + return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 }; + } + const previousYardId = milestones[index - 1].yardId; + const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId)); + if (distanceKm == null) { + const from = yardById.get(previousYardId); + const to = yardById.get(milestone.yardId); + missingPairs.push( + `${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm: distanceKm ?? null, + }; + }); + + if (missingPairs.length > 0) { + throw new BadRequestException( + `No distance configured for: ${missingPairs.join(', ')}. ` + + 'Add the missing yard distances in Configuration → Yard Distances first.', + ); + } + + const originYardId = milestones[0].yardId; + const destinationYardId = milestones[milestones.length - 1].yardId; const direction = deriveTradeDirection( yardById.get(originYardId) ?? { country: null }, yardById.get(destinationYardId) ?? { country: null }, @@ -236,4 +256,17 @@ export class RoutesService { milestones: normalized, }; } + + /** Order-insensitive pair → km map over every configured distance touching the yards. */ + private async loadDistanceLookup(yardIds: string[]): Promise> { + const rows = await this.dataSource + .getRepository(YardDistance) + .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] }); + + const lookup = new Map(); + for (const row of rows) { + lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm)); + } + return lookup; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts new file mode 100644 index 000000000..c43e7e4e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistancesService } from '../services/yard-distances.service'; + +@ApiTags('yard-distances') +@Controller('yard-distances') +@ApiBearerAuth() +export class YardDistancesController { + constructor(private readonly service: YardDistancesService) {} + + @Get() + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'List yard distances' }) + findAll(@Query() query: ListYardDistancesQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @RuleEngineView('yard-distances') + @ApiOperation({ summary: 'Get a yard distance by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Create a yard distance' }) + create(@Body() dto: CreateYardDistanceDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Update a yard distance' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yard-distances') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a yard distance' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts new file mode 100644 index 000000000..0615debdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsUUID, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateYardDistanceDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + fromYardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + toYardId!: string; + + @ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 }) + @Transform(toNumber) + @IsNumber() + @Min(0.01) + distanceKm!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 5718b0531..30241ddaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto { sortBy?: string; } +export class ListYardDistancesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Return only distances touching this yard.' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'distanceKm']) + sortBy?: string; +} + export class ListApprovalRulesQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts new file mode 100644 index 000000000..8c40876ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateYardDistanceDto } from './create-yard-distance.dto'; + +export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts new file mode 100644 index 000000000..982079f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * Configured rail distance between two yards. Route creation reads segment + * kilometres from here (symmetric: A→B serves B→A too) instead of taking + * them as free-text input — see RoutesService.validateMilestones. + * + * Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a + * soft-deleted pair can be re-created. + */ +@Entity({ schema: 'freight', name: 'yard_distances' }) +@Index(['fromYardId']) +@Index(['toYardId']) +export class YardDistance extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard; + + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) + distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts new file mode 100644 index 000000000..ca88ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from '@edr/types'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; + +export interface IYardDistancesRepository { + findById(id: string): Promise; + /** Exact or reverse pair — distances are symmetric (A→B serves B→A). */ + findBetween(fromYardId: string, toYardId: string): Promise; + /** All rows touching any of the given yards, for batch segment lookups. */ + findTouchingYards(yardIds: string[]): Promise; + findPaged(query: ListYardDistancesQueryDto): Promise>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts new file mode 100644 index 000000000..379c92c7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts @@ -0,0 +1,87 @@ +import { PaginatedResponse } from '@edr/types'; +import { Injectable } from '@nestjs/common'; +import { Brackets, DataSource, In, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface'; + +@Injectable() +export class YardDistancesRepository implements IYardDistancesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(YardDistance); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { fromYard: true, toYard: true }, + }); + } + + findBetween(fromYardId: string, toYardId: string): Promise { + return this.repo.findOne({ + where: [ + { fromYardId, toYardId }, + { fromYardId: toYardId, toYardId: fromYardId }, + ], + }); + } + + findTouchingYards(yardIds: string[]): Promise { + if (!yardIds.length) return Promise.resolve([]); + return this.repo.find({ + where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }], + }); + } + + /** Paged list with server-side search on either yard's label/code. */ + findPaged(query: ListYardDistancesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('yardDistance') + .leftJoinAndSelect('yardDistance.fromYard', 'fromYard') + .leftJoinAndSelect('yardDistance.toYard', 'toYard') + .orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC') + .addOrderBy('fromYard.label', 'ASC'); + + if (query.yardId) { + qb.andWhere( + new Brackets((w) => + w + .where('yardDistance.fromYardId = :yardId', { yardId: query.yardId }) + .orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }), + ), + ); + } + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('fromYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }), + ), + ); + } + + return paginateQuery(qb, query); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + const saved = await this.repo.save(entity); + return (await this.findById(saved.id)) ?? saved; + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} 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 95b5e381d..691992c54 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 @@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; @@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; @@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; @@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardDistancesRepository } from './repositories/yard-distances.repository'; import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; @@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceType, WeightLimitRule, Yard, + YardDistance, YardFacility, ShippingLine, Rate, @@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardDistancesController, ShippingLinesController, RatesController, ApprovalRulesController, @@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, YardsRepository, { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + YardDistancesRepository, + { provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository }, ShippingLinesRepository, { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, RatesRepository, @@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesService, WeightLimitRulesService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. SERVICE_TYPES_REPOSITORY, SHIPPING_LINES_REPOSITORY, YARDS_REPOSITORY, + YARD_DISTANCES_REPOSITORY, ], }) export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts new file mode 100644 index 000000000..a41e593e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -0,0 +1,119 @@ +import { PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { + IYardDistancesRepository, + YARD_DISTANCES_REPOSITORY, +} from '../interfaces/yard-distances.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** + * Flat row shape for the backoffice config table: the yard relations stay for + * API consumers, plus label fields the generic rule-engine grid can render. + */ +export type YardDistanceRow = YardDistance & { + fromYardLabel: string; + toYardLabel: string; +}; + +const yardDisplay = (yard?: { label?: string; code?: string } | null): string => + yard?.label ?? yard?.code ?? '—'; + +const toRow = (entity: YardDistance): YardDistanceRow => + Object.assign(entity, { + fromYardLabel: yardDisplay(entity.fromYard), + toYardLabel: yardDisplay(entity.toYard), + }); + +@Injectable() +export class YardDistancesService { + constructor( + @Inject(YARD_DISTANCES_REPOSITORY) + private readonly repository: IYardDistancesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + ) {} + + async findAll(query: ListYardDistancesQueryDto): Promise> { + const page = await this.repository.findPaged(query); + return { ...page, items: page.items.map(toRow) }; + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(entity); + } + + async create(dto: CreateYardDistanceDto): Promise { + await this.assertValidPair(dto.fromYardId, dto.toYardId); + + const created = await this.repository.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + distanceKm: dto.distanceKm.toFixed(2), + }); + return toRow(created); + } + + async update(id: string, dto: UpdateYardDistanceDto): Promise { + const existing = await this.findById(id); + + const fromYardId = dto.fromYardId ?? existing.fromYardId; + const toYardId = dto.toYardId ?? existing.toYardId; + if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) { + await this.assertValidPair(fromYardId, toYardId, id); + } + + const updated = await this.repository.update(id, { + fromYardId, + toYardId, + ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + }); + if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * Both yards must exist and differ, and the pair must not already be + * configured in either direction — distances are symmetric, so an A→B row + * already covers B→A. + */ + private async assertValidPair( + fromYardId: string, + toYardId: string, + ignoreId?: string, + ): Promise { + if (fromYardId === toYardId) { + throw new BadRequestException('From and to yards must be different'); + } + + const [fromYard, toYard] = await Promise.all([ + this.yardsRepository.findById(fromYardId), + this.yardsRepository.findById(toYardId), + ]); + if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`); + if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`); + + const existing = await this.repository.findBetween(fromYardId, toYardId); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A distance between ${fromYard.label} and ${toYard.label} is already configured`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 3658f4f37..662b83534 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -29,6 +29,9 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** Locomotive statuses that block a train from reactivating. */ +const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']); + /** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ export interface ActiveScheduleRef { id: string; @@ -596,11 +599,30 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */ + /** + * Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled + * again. Blocked if any coupled locomotive is unfit for service — a + * deactivated train can sit parked for a while and its locomotives may have + * since been sent to maintenance independently; reactivating must not wave + * a down locomotive back onto the schedule board. + */ async activate(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ where: { id } }); if (!train) throw new NotFoundException(`Train ${id} not found`); if (train.status === Freight.TrainStatus.Deactivated) { + const links = await this.dataSource + .getRepository(TrainLocomotive) + .find({ where: { trainId: id }, relations: { locomotive: true } }); + const unfit = links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)) + .filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status)); + if (unfit.length) { + const names = unfit.map((l) => `${l.code} (${l.status})`).join(', '); + throw new ConflictException( + `Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`, + ); + } await this.dataSource .getRepository(Train) .update(id, { status: Freight.TrainStatus.Available }); 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 d3b5bbb00..396ac95db 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'priority-configs', 'rates', 'approval-rules', + 'yard-distances', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record(null); const [rejectReason, setRejectReason] = useState(""); + // Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an + // earlier APPROVED step to send the chain back to. First approver has no + // choice — customer only. + const [rejectTarget, setRejectTarget] = useState("CUSTOMER"); const steps = useMemo( () => @@ -70,6 +75,7 @@ export function ContractApprovalStepsCard({ const openReject = (step: Freight.IContractApprovalStep) => { setRejectStepRow(step); setRejectReason(""); + setRejectTarget("CUSTOMER"); setRejectOpen(true); }; @@ -77,14 +83,34 @@ export function ContractApprovalStepsCard({ setRejectOpen(false); setRejectStepRow(null); setRejectReason(""); + setRejectTarget("CUSTOMER"); }; const trimmedReason = rejectReason.trim(); + // Earlier stages this rejection can be returned to — only stages that have + // already approved. Empty for the first approver, whose only target is the + // customer. + const returnableSteps = rejectStepRow + ? steps.filter( + (s) => + s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED", + ) + : []; + + const sendBack = rejectTarget !== "CUSTOMER"; + const targetStep = sendBack + ? returnableSteps.find((s) => s.id === rejectTarget) + : undefined; + const runReject = () => { if (!rejectStepRow || !trimmedReason) return; mutations.rejectStep.mutate( - { stepId: rejectStepRow.id, reason: trimmedReason }, + { + stepId: rejectStepRow.id, + reason: trimmedReason, + returnToStepId: sendBack ? rejectTarget : undefined, + }, { onSuccess: () => closeReject() }, ); }; @@ -192,21 +218,56 @@ export function ContractApprovalStepsCard({ centered > - - Rejecting the{" "} - - {rejectStepRow?.requiredRole} - {" "} - step rejects contract{" "} - - {contract.reference} - {" "} - outright. The customer must create a new contract — this cannot be - undone. - + {returnableSteps.length > 0 && ( + -

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

- -
- - - - -
-
-
-
- - -
-
- -
- - - - - -
-
- - - - - - - -
- -
-
- - - - - - - -
JourneyDuplicate Bookings
- - - - - - - diff --git a/booking-extractor.html b/booking-extractor.html deleted file mode 100644 index 844c98b2c..000000000 --- a/booking-extractor.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - EDR Booking Extractor - - - - -

EDR Booking Extractor

- -
- -
- - Drop bookings.json here or click to browse -
-

Accepts a JSON array of bookings or an object with a bookings key.

-
- - - -
-
- -
-
-
- - - -
-
- - - - - - - - - - - - - - - - - - - - - -
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
-
-
- - - - - diff --git a/booking-proxy.mjs b/booking-proxy.mjs deleted file mode 100644 index 27f9248ee..000000000 --- a/booking-proxy.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import http from 'http'; -import https from 'https'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const PORT = 8080; -const __dir = path.dirname(fileURLToPath(import.meta.url)); - -const server = http.createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - // Serve any .html file in the same directory - if (req.url === '/' || req.url.endsWith('.html')) { - const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); - const filepath = path.join(__dir, filename); - if (fs.existsSync(filepath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - fs.createReadStream(filepath).pipe(res); - } else { - res.writeHead(404); res.end('Not found'); - } - return; - } - - // Proxy /proxy?url= - if (req.url.startsWith('/proxy?url=')) { - const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); - const parsed = new URL(target); - const mod = parsed.protocol === 'https:' ? https : http; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: req.method, - headers: { ...req.headers, host: parsed.hostname }, - }; - const proxy = mod.request(options, (apiRes) => { - res.writeHead(apiRes.statusCode, apiRes.headers); - apiRes.pipe(res); - }); - proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); - req.pipe(proxy); - return; - } - - res.writeHead(404); res.end(); -}); - -server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html deleted file mode 100644 index 17be60576..000000000 --- a/ticket-extractor.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - EDR Ticket Extractor - - - - -

EDR Ticket Extractor

- -
- -
- - Drop tickets.json here or click to browse -
-

Accepts a JSON array of tickets or an object with a tickets key.

-
- - - -
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
-
-
- - - - - From c923c88983b609a76e44630d43428e262a23877f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 08:02:23 +0300 Subject: [PATCH 37/38] Tables gagination updates --- .../backoffice/src/app/agents/page.tsx | 8 ++++- .../backoffice/src/app/coaches/page.tsx | 11 +++++-- .../backoffice/src/app/payments/page.tsx | 8 ++++- .../src/app/reports/passengers/page.tsx | 15 ++++++---- .../app/reports/payment-discrepancy/page.tsx | 13 ++++++-- .../backoffice/src/app/reports/seats/page.tsx | 19 ++++++++---- .../backoffice/src/app/schedules/page.tsx | 21 ++++++++----- .../backoffice/src/app/stations/page.tsx | 8 ++++- .../backoffice/src/app/tickets/page.tsx | 30 ++++++++++++------- .../backoffice/src/app/trains/page.tsx | 7 ++++- .../backoffice/src/lib/use-pagination.ts | 18 +++++++++++ 11 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/lib/use-pagination.ts diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index 9efd7ab19..b2967c58d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -9,6 +9,8 @@ import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { agentsApi, apiClient } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -90,6 +92,9 @@ export default function AgentsPage() { queryFn: () => agentsApi.getAll(filters), }); + const allAgents = data?.items || []; + const { paged: pagedAgents, page, totalPages, setPage } = usePagination(allAgents, 20); + const columns = [ { key: 'agentCode', @@ -182,12 +187,13 @@ export default function AgentsPage() { + = { passenger: 'edr-badge-info', sleeper: 'edr-badge-warning', @@ -551,11 +556,12 @@ export default function CoachesPage() { + )} @@ -574,11 +580,12 @@ export default function CoachesPage() { + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index b9c81a183..9cbfdd4e3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { paymentsApi, apiClient } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import SupplementaryChargesModal from './SupplementaryChargesModal'; import { @@ -109,6 +111,9 @@ export default function PaymentsPage() { }), }); + const allPayments = (data as any)?.items || (Array.isArray(data) ? data : []); + const { paged: pagedPayments, page: paymentsPage, totalPages: paymentsTotalPages, setPage: setPaymentsPage } = usePagination(allPayments, 20); + const PAYMENT_COLS = [ { key: 'reference', label: 'Reference' }, { key: 'booking', label: 'Booking Reference' }, @@ -311,12 +316,13 @@ export default function PaymentsPage() { + {/* Payment Details Modal */} setSelectedPayment(null)} title="Payment Details" size="xl"> diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index d4d78ab01..35b6d6cc7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -6,6 +6,8 @@ import { Users, Armchair, BarChart3, Train, Download } from "lucide-react"; import { apiClient } from "@/lib/api-client"; import { formatDateTime } from "@/lib/utils"; import ActionButton from "@/components/ui/ActionButton"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; interface ScheduleOption { id: string; @@ -127,6 +129,8 @@ export default function PassengersReportPage() { }) .sort((a, b) => a.bookingRef.localeCompare(b.bookingRef)); + const { paged: pagedList, page: listPage, totalPages: listTotalPages, setPage: setListPage, reset: resetListPage } = usePagination(filteredList, 50); + const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); @@ -457,12 +461,12 @@ export default function PassengersReportPage() { className="input max-w-sm flex-1" placeholder="Search by name or booking ref…" value={listSearch} - onChange={(e) => setListSearch(e.target.value)} + onChange={(e) => { setListSearch(e.target.value); resetListPage(); }} /> setFilterCoachNumber(e.target.value)} + onChange={(e) => { setFilterCoachNumber(e.target.value); resetListPage(); }} > {coachNumberOptions.map((c) => ( @@ -486,7 +490,7 @@ export default function PassengersReportPage() { setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")} + onChange={(e) => { setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID"); resetSeatsPage(); }} > @@ -280,7 +285,7 @@ export default function SeatStatusReportPage() { - {filtered.map((row, i) => { + {pagedSeats.map((row, i) => { const isPaid = row.bookingStatus === "CONFIRMED" || row.bookingStatus === "BOARDED"; return ( @@ -306,7 +311,7 @@ export default function SeatStatusReportPage() { ); })} - {filtered.length === 0 && ( + {pagedSeats.length === 0 && ( No seats found @@ -316,6 +321,7 @@ export default function SeatStatusReportPage() { + } {/* Blocked Seats Tab */} @@ -338,12 +344,12 @@ export default function SeatStatusReportPage() { - {data.blockedSeats.length === 0 && ( + {pagedBlocked.length === 0 && ( No blocked seats )} - {data.blockedSeats.map((b) => ( + {pagedBlocked.map((b) => ( {b.seatClassName ?? "—"} @@ -365,6 +371,7 @@ export default function SeatStatusReportPage() { + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 4ef9b97ed..b9b2765a7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -9,6 +9,8 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; import { routeCoachTemplatesApi } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; interface Schedule { @@ -368,6 +370,8 @@ export default function SchedulesPage() { ); }); + const { paged: pagedSchedules, page: schedulePage, totalPages: scheduleTotalPages, setPage: setSchedulePage } = usePagination(filteredSchedules as Schedule[], 20); + const statusMap: Record = { SCHEDULED: 'edr-badge-info', BOARDING: 'edr-badge-warning', @@ -621,13 +625,16 @@ export default function SchedulesPage() { No schedules found. {filters.search && 'Try adjusting your search.'} ) : ( - + <> + + + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 063f4cab8..032a978a4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { stationsApi } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; export default function StationsPage() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); @@ -90,6 +92,9 @@ export default function StationsPage() { } }; + const stationItems = data?.items || []; + const { paged: pagedStations, page, totalPages, setPage } = usePagination(stationItems, 20); + const handleDelete = (station: any) => { setDeleteConfirm({ isOpen: true, station, error: undefined }); }; @@ -231,12 +236,13 @@ export default function StationsPage() { {/* Stations Table */} + {/* Delete Confirmation */} setTicketPage(1); + const PAGE_SIZE = 50; const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -65,7 +69,7 @@ export default function TicketsPage() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: ['tickets', filters], + queryKey: ['tickets', filters, ticketPage], queryFn: () => ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, @@ -76,11 +80,14 @@ export default function TicketsPage() { dateFrom: filters.dateFrom || undefined, dateTo: filters.dateTo || undefined, coachId: filters.coachId || undefined, - skip: 0, - take: 50, + skip: (ticketPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, }), }); + const ticketMeta = (data as any)?.meta; + const ticketTotalPages = ticketMeta ? ticketMeta.totalPages : Math.max(1, Math.ceil(((data as any)?.total ?? (data?.items?.length ?? 0)) / PAGE_SIZE)); + const { data: stationsData } = useQuery({ queryKey: ['stations'], queryFn: () => stationsApi.getAll(), @@ -562,7 +569,7 @@ export default function TicketsPage() { placeholder="Search by ticket number..." className="input" value={filters.search} - onChange={(e) => setFilters({ ...filters, search: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, search: e.target.value }); }} />
@@ -570,7 +577,7 @@ export default function TicketsPage() { setFilters({ ...filters, destinationStationId: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, destinationStationId: e.target.value }); }} > {stations.map((station: any) => ( @@ -597,7 +604,7 @@ export default function TicketsPage() { type="date" className="input" value={filters.departureDate} - onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, departureDate: e.target.value }); }} />
@@ -605,7 +612,7 @@ export default function TicketsPage() { setFilters({ ...filters, status: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, status: e.target.value }); }} > @@ -638,12 +645,12 @@ export default function TicketsPage() {
setFilters({ ...filters, dateFrom: e.target.value })} /> + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateFrom: e.target.value }); }} />
setFilters({ ...filters, dateTo: e.target.value })} /> + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateTo: e.target.value }); }} />
)} @@ -657,6 +664,7 @@ export default function TicketsPage() { loading={isLoading} emptyMessage="No tickets found" /> + {/* Board Confirmation Modal */} + {/* Delete Confirmation */} (items: T[], pageSize = 20) { + const [page, setPage] = useState(1); + + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const safePage = Math.min(page, totalPages); + + const paged = useMemo( + () => items.slice((safePage - 1) * pageSize, safePage * pageSize), + [items, safePage, pageSize], + ); + + // Reset to page 1 whenever the source list changes length (e.g. after a filter) + const reset = () => setPage(1); + + return { paged, page: safePage, totalPages, setPage, reset }; +} From 0d8e122de77af383db0d0b7df2c9e1c7eccf7bf7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 08:51:33 +0300 Subject: [PATCH 38/38] Ticket generation updates --- .../src/app/booking/confirmation/page.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 1039b1465..e9ed308d8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -112,6 +112,15 @@ export default function ConfirmationPage() { // backgrounded tabs, so that can take a very long time. staleTime: 0, enabled: !!bookingId, + // Keep polling after CONFIRMED until tickets are issued — ticket generation runs + // async after the booking transaction commits (see finalizePaymentSuccess in + // payments.service.ts), so the first CONFIRMED fetch often returns an empty + // tickets array. + refetchInterval: (query) => { + const data = query.state.data; + if (!data || data.status !== "CONFIRMED") return false; + return (data.tickets?.length ?? 0) >= passengers.length ? false : FAST_POLL_INTERVAL_MS; + }, }); // Poll the payment intent while the booking is PENDING_PAYMENT — fast during the @@ -153,7 +162,7 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!pnr) { + if (!pnr || !bookingId) { alert("Booking data not available. Please try again."); return; } @@ -164,23 +173,28 @@ export default function ConfirmationPage() { setIsGeneratingVoucher(true); try { - const { generatePassengerVoucherPDF } = - await import("@/lib/generate-voucher"); + const { generatePassengerVoucherPDF } = await import("@/lib/generate-voucher"); + + // Always fetch fresh booking data so tickets are present even if the cached + // _booking raced ahead of ticket generation (tickets are written async after + // the booking is confirmed — see finalizePaymentSuccess in payments.service.ts). + const freshBooking: BookingWithTicket = await apiClient.get(`/bookings/${bookingId}`); + const bookingData = freshBooking ?? _booking; const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; // The server-confirmed settled amount/currency (what was actually charged) is // authoritative — prefer it over the ETB booking fare once available. Shown exactly // as returned by the API (no /100, no per-passenger split) on every passenger's // voucher — see fareIsMajorUnits below. - const settledAmountMinor = _booking?.payment?.amountMinor; - const settledCurrency = _booking?.payment?.currency; + const settledAmountMinor = bookingData?.payment?.amountMinor; + const settledCurrency = bookingData?.payment?.currency; const hasSettledAmount = settledAmountMinor != null && !!settledCurrency; // Derive display currency from nationality (same logic as review/payment pages) const nat = (searchCriteria?.nationality ?? '').toUpperCase(); const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'; const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency; - const createdAt = _booking?.createdAt || new Date().toISOString(); - const status = _booking?.status || "CONFIRMED"; + const createdAt = bookingData?.createdAt || new Date().toISOString(); + const status = bookingData?.status || "CONFIRMED"; // Compute per-passenger fares (in ETB) using the same logic as the review/payment // pages. reviewedPassengerFares is the authoritative source; rebuild from package @@ -206,7 +220,7 @@ export default function ConfirmationPage() { return isPkgChild ? pkgChildFare : pkgAdultFare; } const totalFare = - reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0; + reviewedTotalMinor ?? paidAmountMinor ?? bookingData?.totalMinor ?? 0; return Math.round(totalFare / passengers.length); }; @@ -254,11 +268,9 @@ export default function ConfirmationPage() { // synchronous user-activation window and risk iOS Safari silently blocking them. for (let i = 0; i < passengers.length; i++) { const p = passengers[i]; - // Same match-by-name-then-position as the on-screen ticket list above — no - // fabricated placeholder if there's no backend ticket data (see generate-voucher.ts). const matchedTicket = - _booking?.tickets?.find((t) => t.passengerName === p.name) ?? - _booking?.tickets?.[i] ?? + bookingData?.tickets?.find((t) => t.passengerName === p.name) ?? + bookingData?.tickets?.[i] ?? null; const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";