From 2e22b72e2786d277b651fa4d493d3fcdfb9bf387 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 11:07:45 +0000
Subject: [PATCH 01/11] feat(warehouse): assemble dashboard cockpit +
server-side throughput series
- WarehouseDashboardPage now composes the ops KPI strip, lifecycle cards, flow
charts, zone-occupancy heatmap and demurrage/accrual exceptions into one
control-tower view; drop the redundant lifecycle donut.
- New GET /warehouse-inventory/throughput (date_trunc time series) replaces the
client-side buildTrend that downloaded the entire inventory list to bucket it.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouse-inventory.controller.ts | 8 +
.../warehouses/warehouse-inventory.service.ts | 47 ++++++
.../warehouses/WarehouseDashboardCharts.tsx | 139 ++++--------------
.../backoffice/src/constants/URLS.ts | 2 +
.../backoffice/src/hooks/useWarehouses.ts | 8 +
.../warehouses/WarehouseDashboardPage.tsx | 45 +++++-
.../src/services/warehouse.service.ts | 5 +
.../backoffice/src/types/warehouse.ts | 7 +
8 files changed, 142 insertions(+), 119 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 8b14e6cdc..e9e8d95db 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -73,6 +73,14 @@ export class WarehouseInventoryController {
return this.inventoryService.zoneOccupancy(yardId);
}
+ @Get('throughput')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' })
+ throughput(@Query('granularity') granularity?: string) {
+ const g = granularity === 'week' || granularity === 'year' ? granularity : 'month';
+ return this.inventoryService.throughput(g);
+ }
+
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
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 9a42bc895..793372d84 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
@@ -436,6 +436,53 @@ export class WarehouseInventoryService {
};
}
+ /**
+ * Received-vs-dispatched throughput as a server-side time series. Buckets by
+ * date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
+ * generate_series so empty periods still return a zero row — replaces the
+ * client-side approach that downloaded the whole inventory to bucket it.
+ */
+ async throughput(
+ granularity: 'week' | 'month' | 'year' = 'month',
+ ): Promise> {
+ // Whitelist the unit — it is interpolated into date_trunc / interval literals.
+ const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
+ ? granularity
+ : 'month';
+ const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
+
+ const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
+ await this.dataSource.query(
+ `WITH periods AS (
+ SELECT gs AS period_start
+ FROM generate_series(
+ date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
+ date_trunc('${unit}', now()),
+ '1 ${unit}'::interval
+ ) gs
+ )
+ SELECT p.period_start AS "periodStart",
+ COALESCE(r.cnt, 0)::int AS received,
+ COALESCE(d.cnt, 0)::int AS dispatched
+ FROM periods p
+ LEFT JOIN (
+ SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
+ GROUP BY 1
+ ) r ON r.ps = p.period_start
+ LEFT JOIN (
+ SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
+ GROUP BY 1
+ ) d ON d.ps = p.period_start
+ ORDER BY p.period_start`,
+ [back],
+ );
+ return rows;
+ }
+
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx
index 993544fe3..71700fcf3 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx
@@ -1,24 +1,20 @@
-import { useMemo, useState } from 'react';
-import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
-import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
+import { useState } from 'react';
+import { Card, Group, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core';
+import { BarChart3, CalendarRange } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
- Pie,
- PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
-import { useQuery } from '@tanstack/react-query';
-
-import { api } from '@/services/api';
-import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
+import { useWarehouseThroughput } from '@/hooks/useWarehouses';
+import type { WarehouseDashboard } from '@/types/warehouse';
interface WarehouseDashboardChartsProps {
data?: WarehouseDashboard;
@@ -38,11 +34,25 @@ const STATUS_SERIES = [
type Granularity = 'week' | 'month' | 'year';
+/** Label a period start according to the selected granularity. */
+function formatPeriod(iso: string, granularity: Granularity): string {
+ const d = new Date(iso);
+ if (granularity === 'year') return String(d.getFullYear());
+ if (granularity === 'week') return d.toLocaleDateString('en', { day: 'numeric', month: 'short' });
+ return d.toLocaleDateString('en', { month: 'short' });
+}
+
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState('month');
- const { data: inventory } = useQuery(
- api.warehouses.listInventory.queryOptions({ input: {} }),
- );
+ // Server-side time series (replaces downloading the whole inventory to bucket).
+ const { data: series = [] } = useWarehouseThroughput(granularity);
+
+ const trend = series.map((p) => ({
+ label: formatPeriod(p.periodStart, granularity),
+ received: p.received,
+ dispatched: p.dispatched,
+ }));
+ const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
const statusData = STATUS_SERIES.map((s) => ({
name: s.label,
@@ -51,16 +61,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
}));
const hasStatus = statusData.some((d) => d.value > 0);
- const trend = useMemo(
- () => buildTrend(inventory ?? [], granularity),
- [inventory, granularity],
- );
- const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
-
return (
-
+
{/* Time-filtered throughput */}
-
+
@@ -133,103 +137,10 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
)}
-
- {/* Status distribution donut */}
-
-
-
-
-
-
- Lifecycle Distribution
-
- Share of inventory across statuses
-
-
-
-
- {hasStatus ? (
-
-
-
- {statusData.map((entry) => (
- |
- ))}
-
-
-
-
-
- ) : (
-
- )}
-
-
+
);
}
-interface TrendBucket {
- label: string;
- received: number;
- dispatched: number;
-}
-
-/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
-function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
- const now = new Date();
- const buckets: { label: string; start: Date; end: Date }[] = [];
-
- if (granularity === 'week') {
- for (let i = 7; i >= 0; i--) {
- const end = new Date(now);
- end.setDate(now.getDate() - i * 7);
- const start = new Date(end);
- start.setDate(end.getDate() - 7);
- buckets.push({ label: `W${8 - i}`, start, end });
- }
- } else if (granularity === 'month') {
- for (let i = 11; i >= 0; i--) {
- const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
- const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
- buckets.push({
- label: start.toLocaleString('en', { month: 'short' }),
- start,
- end,
- });
- }
- } else {
- for (let i = 4; i >= 0; i--) {
- const year = now.getFullYear() - i;
- buckets.push({
- label: String(year),
- start: new Date(year, 0, 1),
- end: new Date(year + 1, 0, 1),
- });
- }
- }
-
- const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
- if (!iso) return false;
- const t = new Date(iso).getTime();
- return t >= start.getTime() && t < end.getTime();
- };
-
- return buckets.map((b) => ({
- label: b.label,
- received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
- dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
- }));
-}
-
function EmptyChart() {
return (
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 618d0ba30..37564b0fe 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -487,6 +487,8 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
+ THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
+ `/warehouse-inventory/throughput?granularity=${granularity}`,
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index 56ef4876b..8b10bfbd4 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -152,6 +152,14 @@ export function useWarehouseOpsStats() {
});
}
+/** Server-side received-vs-dispatched throughput time series. */
+export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'throughput', granularity],
+ queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
+ });
+}
+
/** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
index 8524a7e68..8a5ffd08c 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
-import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
+import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -16,10 +16,23 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
-import { WarehouseDashboardCharts } from '@/components/warehouses';
+import {
+ AccrualDashboard,
+ WarehouseDashboardCharts,
+ WarehouseOpsKpiStrip,
+ ZoneOccupancyHeatmap,
+} from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
interface Metric {
key: keyof WarehouseDashboard;
label: string;
@@ -67,7 +80,16 @@ export default function WarehouseDashboardPage() {
Failed to load warehouse dashboard.
) : (
- <>
+
+ {/* Needs attention — live ops counters (received today, pending
+ inspection, trucks on-site, items aging > 7 days). */}
+
+ Needs attention
+
+
+
+
+
{METRICS.map((metric) => (
-
- >
+
+ Flow
+
+
+
+
+ Zone capacity
+
+
+
+
+ Demurrage & storage exceptions
+
+
+
)}
);
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index 53ba263a5..4a73ee1d3 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -6,6 +6,7 @@ import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
WarehouseOpsStats,
+ WarehouseThroughputPoint,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
@@ -394,6 +395,10 @@ export const warehouseService = {
),
opsStats: () =>
apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
+ throughput: (granularity: 'week' | 'month' | 'year') =>
+ apiClient.get(
+ URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
+ ),
autoUnloadArrived: () =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 348646dbb..eb7d53739 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1120,6 +1120,13 @@ export interface WarehouseOpsStats {
itemsAging: number;
}
+/** One bucket of the received-vs-dispatched throughput time series. */
+export interface WarehouseThroughputPoint {
+ periodStart: string;
+ received: number;
+ dispatched: number;
+}
+
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */
From 19c9da28ae5a027bd48173b0f85a8de1fd00fb90 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 13:29:01 +0000
Subject: [PATCH 02/11] changes
---
.../contract-document-view-model.builder.ts | 56 ++-
.../2210000000000-ScheduleScopedWagonPins.ts | 48 ++
...20000000000-AddContractDocumentSnapshot.ts | 27 +
...0000-RenameWagonStatusRetiredToDetained.ts | 24 +
.../2240000000000-AddTransferRequestReason.ts | 24 +
...000000-CreatePriorityRuleChangeRequests.ts | 42 ++
.../bookings/booking-transition.service.ts | 22 +-
.../modules/bookings/bookings.controller.ts | 22 +
.../src/modules/bookings/bookings.service.ts | 73 ++-
.../contracts/contract-transition.service.ts | 242 ++++++++-
.../modules/contracts/contracts.controller.ts | 26 +
.../contracts/dto/accept-contract.dto.ts | 19 +-
.../contracts/dto/contract-document.dto.ts | 64 +++
.../contracts/entities/contract.entity.ts | 45 ++
...riority-rule-change-requests.controller.ts | 72 +++
.../dto/priority-rule-change-request.dto.ts | 49 ++
.../priority-rule-change-request.entity.ts | 46 ++
.../modules/rule-engine/rule-engine.module.ts | 10 +
.../services/priority-configs.service.ts | 50 ++
.../priority-rule-change-requests.service.ts | 226 +++++++++
.../dto/available-days-for-cargo-query.dto.ts | 22 +
.../train-scheduling.controller.ts | 2 +
.../train-scheduling.service.ts | 461 +++++++++++++++---
.../modules/trains/train-builder.service.ts | 27 +-
.../dto/bulk-fulfill-transfer-requests.dto.ts | 13 +
.../wagons/dto/create-transfer-request.dto.ts | 22 +-
.../entities/wagon-transfer-request.entity.ts | 7 +
.../modules/wagons/entities/wagon.entity.ts | 2 +-
.../wagon-transfer-requests.controller.ts | 17 +
.../wagons/wagon-transfer-requests.service.ts | 102 +++-
.../warehouses/scheduling-read.facade.ts | 2 +-
.../contracts/ContractActionsToolbar.tsx | 160 +++---
.../contracts/ContractDocumentEditorModal.tsx | 413 ++++++++++++++++
.../src/components/fleet/fleetFormat.tsx | 2 +-
.../wagons/WagonTransferRequestsModal.tsx | 104 +++-
.../wagons/WagonYardWorkspaceModal.tsx | 53 +-
.../backoffice/src/constants/QUERY_KEYS.ts | 1 +
.../backoffice/src/constants/URLS.ts | 3 +
.../src/hooks/contracts/useContracts.ts | 54 +-
.../src/hooks/rule-engine/useRuleEngine.ts | 68 ++-
.../src/pages/fleet/FleetCrudPages.tsx | 2 +-
.../src/pages/fleet/config/resources.ts | 2 +-
.../PriorityRuleApprovalsSection.tsx | 125 +++++
.../ruleEngine/RuleEngineResourcePage.tsx | 71 ++-
.../TrainScheduleV2DetailPage.tsx | 39 +-
.../backoffice/src/services/api.ts | 10 +
.../src/services/contracts.service.ts | 31 +-
.../services/ruleEngine/ruleEngine.service.ts | 64 +++
.../backoffice/src/services/wagon.service.ts | 15 +
.../backoffice/src/types/trainScheduling.ts | 2 +
.../src/pages/bookings/NewBookingPage.tsx | 58 +++
.../bookings/clearance/ClearanceFlow.tsx | 6 +-
.../clearance/OperationDatePicker.tsx | 19 +-
.../bookings/new-booking-form/step4-route.tsx | 34 +-
.../new-booking-form/step8-review.tsx | 18 +-
.../portal/src/services/api.ts | 6 +
.../portal/src/services/bookings.service.ts | 21 +-
packages/types/src/freight/contracts.ts | 38 ++
packages/types/src/freight/index.ts | 9 +-
59 files changed, 3008 insertions(+), 284 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
create mode 100644 apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
create mode 100644 apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
create mode 100644 apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
create mode 100644 apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/PriorityRuleApprovalsSection.tsx
diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
index 559415fd8..a298b8dcb 100644
--- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts
@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
-import { Contract } from '../modules/contracts/entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentSnapshot,
+} from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
@@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
-import { ContractViewModel } from './contract-view-model.builder';
+import {
+ ContractDynamicTemplateView,
+ ContractViewModel,
+} from './contract-view-model.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder {
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
let template = getTemplateMeta(templateKey);
- // Prefer the admin-editable DB template matching the contract's
- // direction/freight pair; fall back to the code-defined generic layout
- // when none is active.
- const dynamicSource = await this.contractTemplates.findActiveForContract(
- contract.tradeDirection,
- contract.freightType,
- );
- const dynamicTemplate = dynamicSource
- ? {
- code: dynamicSource.code,
- name: dynamicSource.name,
- documentTitle: dynamicSource.documentTitle,
- whereasClauses: dynamicSource.whereasClauses ?? [],
- articles: dynamicSource.articles ?? [],
- }
- : undefined;
+ // The document articles come, in order of preference, from:
+ // 1. this contract's frozen snapshot (staff accepted / edited it) — the
+ // shared six templates are never consulted for these contracts;
+ // 2. the admin-editable DB template matching the direction/freight pair;
+ // 3. the code-defined generic layout (handled below when none of the above).
+ const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null;
+ let dynamicTemplate: ContractDynamicTemplateView | undefined;
+ if (snapshot && (snapshot.articles?.length ?? 0) > 0) {
+ dynamicTemplate = {
+ code: snapshot.code ?? 'CONTRACT',
+ name: snapshot.name ?? template.title,
+ documentTitle: snapshot.documentTitle ?? '',
+ whereasClauses: snapshot.whereasClauses ?? [],
+ articles: snapshot.articles,
+ };
+ } else {
+ const dynamicSource = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ dynamicTemplate = dynamicSource
+ ? {
+ code: dynamicSource.code,
+ name: dynamicSource.name,
+ documentTitle: dynamicSource.documentTitle,
+ whereasClauses: dynamicSource.whereasClauses ?? [],
+ articles: dynamicSource.articles ?? [],
+ }
+ : undefined;
+ }
if (dynamicTemplate) {
template = {
...template,
diff --git a/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
new file mode 100644
index 000000000..42e8f1dc8
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts
@@ -0,0 +1,48 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Schedule-scoped wagon pins.
+ *
+ * Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
+ * (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
+ * Wagon entity, so the same physical wagon can serve many schedules (the July 17
+ * and July 20 runs of one train both use its 50 wagons). The Wagon columns
+ * `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
+ * meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
+ * cleared at arrive/unload/cancel).
+ *
+ * This migration erases the legacy pin-time stamps left by the old flow: any
+ * wagon pointing at a schedule that is not currently DISPATCHED (or that no
+ * longer exists) gets its pointers cleared, and — when the old flow had parked
+ * it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
+ * while coupled to a built train, otherwise AVAILABLE).
+ */
+export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
+ name = "ScheduleScopedWagonPins2210000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons w
+ SET current_train_schedule_id = NULL,
+ train_set_wagon_id = NULL,
+ status = CASE
+ WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
+ ELSE w.status
+ END
+ WHERE w.deleted_at IS NULL
+ AND w.current_train_schedule_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1
+ FROM freight.train_schedules ts
+ WHERE ts.id = w.current_train_schedule_id
+ AND ts.deleted_at IS NULL
+ AND ts.status = 'DISPATCHED'
+ );
+ `);
+ }
+
+ public async down(_queryRunner: QueryRunner): Promise {
+ // Pin-time stamps cannot be reconstructed (the data was the bug); the
+ // slots on train_set_wagons still hold every live pin, so down is a no-op.
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
new file mode 100644
index 000000000..5a8cdd035
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
+ * contract-document template (articles + WHEREAS recitals) captured at staff
+ * accept. Staff can edit these articles for a single contract before generating
+ * its PDF; the edit never touches the shared six freight.contract_templates
+ * rows. Null on existing contracts → the PDF keeps rendering from the live
+ * template, so this is backward compatible.
+ */
+export class AddContractDocumentSnapshot2220000000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ DROP COLUMN IF EXISTS document_snapshot;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
new file mode 100644
index 000000000..2fe7c726d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
+ * The column is a plain varchar, so this is a data-only rename. Vehicles keep
+ * their own RETIRED status — only freight.wagons rows are touched.
+ */
+export class RenameWagonStatusRetiredToDetained2230000000000
+ implements MigrationInterface
+{
+ name = 'RenameWagonStatusRetiredToDetained2230000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
new file mode 100644
index 000000000..76a59b0f7
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts
@@ -0,0 +1,24 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Every new wagon-transfer request must state WHY the wagons are needed; the
+ * reason is shown on the OCC request queue. Nullable in the DB — legacy rows
+ * predate the requirement; the DTO enforces it for new requests.
+ */
+export class AddTransferRequestReason2240000000000 implements MigrationInterface {
+ name = 'AddTransferRequestReason2240000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ ADD COLUMN IF NOT EXISTS reason text NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_transfer_requests
+ DROP COLUMN IF EXISTS reason
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
new file mode 100644
index 000000000..f93fc7c95
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts
@@ -0,0 +1,42 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Approval workflow for priority-rule changes: every create/update/delete of a
+ * priority config is filed here as a PENDING change request; an approver
+ * applies or rejects it. `payload` carries the proposed field values (null for
+ * DELETE), `priority_config_id` the target row (null for CREATE).
+ */
+export class CreatePriorityRuleChangeRequests2250000000000
+ implements MigrationInterface
+{
+ name = 'CreatePriorityRuleChangeRequests2250000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ action varchar(10) NOT NULL,
+ priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
+ payload jsonb NULL,
+ status varchar(10) NOT NULL DEFAULT 'PENDING',
+ requested_by_user_id uuid NULL,
+ decided_by_user_id uuid NULL,
+ decided_at timestamptz NULL,
+ decision_note text NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz NULL
+ )
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_prcr_status
+ ON freight.priority_rule_change_requests (status)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
index 7b1522446..698759901 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
}
// The binding shipment day must have at least one OPEN departure on the
- // route — only schedule-backed days are selectable. The batch engine
- // assigns the specific train within that (route, day) pool later.
- const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
- booking.originYardId,
- booking.destinationYardId,
- eatDay(date),
- );
+ // route — only schedule-backed days are selectable — AND some departure
+ // that day must be able to physically carry this cargo type (wagon-TYPE
+ // gate; quantity never blocks — oversized bookings get a partial split
+ // offer). The batch engine assigns the specific train within that
+ // (route, day) pool later.
+ const { hasDeparture, hasCompatible } =
+ await this.bookingsService.checkDayCompatibilityForBooking(
+ booking,
+ eatDay(date),
+ );
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ "No wagon on the selected day can carry this cargo type — please choose another day",
+ );
+ }
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index f556751fb..53554b2e3 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
+ @Get(':id/available-days')
+ @ApiOperation({
+ summary:
+ 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
+ })
+ async availableDays(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ const booking = await this.bookingsService.findById(id);
+ if (
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
+ !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
+ ) {
+ await this.bookingsService.assertCustomerCanAccessBooking(
+ user?.id,
+ booking,
+ );
+ }
+ return this.bookingsService.availableDaysForBooking(id);
+ }
+
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index dd5011df3..4aade3bf2 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
- // that EAT day. The booking wizard does NOT send scheduledDate at creation
- // — it captures a non-binding estimatedShipmentDate instead, and the
- // binding day is chosen later at the operation-request step. General
- // contracts also skip this (each drawdown order validates its own day).
+ // that EAT day AND that some departure that day can physically carry the
+ // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
+ // a partial split offer later). The booking wizard does NOT send
+ // scheduledDate at creation — it captures a non-binding
+ // estimatedShipmentDate instead, and the binding day is chosen later at
+ // the operation-request step. General contracts also skip this (each
+ // drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
- const hasDeparture =
- await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
+ const { hasDeparture, hasCompatible } =
+ await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId,
dto.destinationYardId,
day,
+ {
+ freightType: dto.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: dto.cargoTypeId,
+ containerTypeIds: (dto.containers ?? [])
+ .map((c) => c.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ },
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
+ if (!hasCompatible) {
+ throw new BadRequestException(
+ 'No wagon on the selected day can carry this cargo type — please choose another day',
+ );
+ }
}
const containers = dto.containers ?? [];
@@ -1149,6 +1164,52 @@ export class BookingsService {
);
}
+ /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
+ private cargoIdentityOf(booking: Booking): {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ containerTypeIds?: string[];
+ } {
+ return {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
+ containerTypeIds: (booking.bookingContainers ?? [])
+ .map((line) => line.containerTypeId)
+ .filter((id): id is string => Boolean(id)),
+ };
+ }
+
+ /**
+ * Day gate for a specific booking: OPEN departure exists AND some departure
+ * that day can physically carry the booking's cargo/container type.
+ * Quantity never blocks — oversized bookings get a partial split offer.
+ */
+ async checkDayCompatibilityForBooking(
+ booking: Booking,
+ day: string,
+ ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
+ return this.trainSchedulingService.checkDayCargoCompatibility(
+ booking.originYardId,
+ booking.destinationYardId,
+ day,
+ this.cargoIdentityOf(booking),
+ );
+ }
+
+ /**
+ * Days the customer may pick for THIS booking (operation-request step):
+ * cargo-aware — only days whose departures can carry the booking's cargo
+ * type. Returns days only, no capacity counts.
+ */
+ async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
+ const booking = await this.findById(bookingId);
+ return this.trainSchedulingService.getAvailableDaysForCargo({
+ originYardId: booking.originYardId,
+ destinationYardId: booking.destinationYardId,
+ ...this.cargoIdentityOf(booking),
+ });
+ }
+
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
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 4da2677bd..049db2a94 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
@@ -4,6 +4,7 @@ import {
Injectable,
Logger,
} from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
+import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
-import { Contract } from './entities/contract.entity';
+import {
+ Contract,
+ ContractDocumentArticle,
+ ContractDocumentSnapshot,
+ ContractDocumentSnapshotInput,
+} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
+/** The editable contract-document draft returned for the accept/edit dialog. */
+export interface ContractDocumentDraft {
+ documentTitle: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+ code: string | null;
+ name: string | null;
+ /** True once the document may no longer be edited/regenerated. */
+ locked: boolean;
+ generatedAt: Date | null;
+ status: string;
+}
+
/**
* Dropdown-settings code holding the admin-configured contract validity options
* (each option's `value` is a day count). The staff accept dialog reads the same
@@ -68,6 +88,7 @@ export class ContractTransitionService {
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
+ private readonly contractTemplates: ContractTemplatesService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -110,6 +131,7 @@ export class ContractTransitionService {
contractId: string,
actorId: string,
validityDays: number,
+ documentSnapshot?: ContractDocumentSnapshotInput | null,
): Promise {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
@@ -128,6 +150,12 @@ export class ContractTransitionService {
await this.instantiateApprovalSteps(contract);
+ // Freeze the contract document for THIS contract only. Staff may have edited
+ // the articles in the accept dialog; otherwise the live template is captured
+ // as-is so later template edits never change an in-flight contract. The
+ // shared six templates are never written here.
+ const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
+
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
@@ -135,12 +163,148 @@ export class ContractTransitionService {
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
+ documentSnapshot: snapshot,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
}
+ // ── Per-contract document snapshot (US: edit articles for one contract) ─────
+
+ /**
+ * The editable document draft for the accept/edit dialog: the frozen snapshot
+ * if one exists, else the live active template resolved for this contract's
+ * direction/freight pair. `locked` flips true once the document may no longer
+ * be edited (an approver has acted, or the contract has left the pre-approval
+ * window).
+ */
+ async getContractDocumentDraft(
+ contractId: string,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ const snapshot =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ return {
+ documentTitle: snapshot?.documentTitle ?? null,
+ whereasClauses: snapshot?.whereasClauses ?? [],
+ articles: snapshot?.articles ?? [],
+ code: snapshot?.code ?? null,
+ name: snapshot?.name ?? null,
+ locked: !this.documentIsEditable(contract),
+ generatedAt: contract.contractGeneratedAt ?? null,
+ status: contract.status,
+ };
+ }
+
+ /**
+ * Replace this contract's document articles from the editor. Per-contract
+ * only — it writes the contract's own snapshot and never the shared templates.
+ * Allowed while the document is still editable (PENDING_APPROVAL, no approver
+ * has acted).
+ */
+ async updateContractDocument(
+ contractId: string,
+ input: ContractDocumentSnapshotInput,
+ ): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ assertContractStatus(contract, ['PENDING_APPROVAL']);
+ this.assertDocumentEditable(contract);
+
+ const current =
+ (contract.documentSnapshot as ContractDocumentSnapshot | null) ??
+ (await this.resolveDocumentSnapshot(contract));
+ const merged: ContractDocumentSnapshotInput = {
+ code: current?.code ?? null,
+ name: input.name ?? current?.name ?? null,
+ documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
+ whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
+ articles: input.articles ?? current?.articles ?? [],
+ };
+ await this.contractsRepository.update(contractId, {
+ documentSnapshot: this.normalizeSnapshot(merged),
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
+
+ /**
+ * Build the per-contract document snapshot. Prefer the staff's edited articles
+ * from the dialog; otherwise freeze the active template matching the
+ * contract's direction/freight. Returns null when no active template exists
+ * (the renderer then falls back to the built-in generic layout at render time).
+ */
+ private async resolveDocumentSnapshot(
+ contract: Contract,
+ provided?: ContractDocumentSnapshotInput | null,
+ ): Promise {
+ if (provided && (provided.articles?.length ?? 0) > 0) {
+ return this.normalizeSnapshot(provided);
+ }
+ const active = await this.contractTemplates.findActiveForContract(
+ contract.tradeDirection,
+ contract.freightType,
+ );
+ if (!active) return null;
+ return {
+ code: active.code,
+ name: active.name,
+ documentTitle: active.documentTitle,
+ whereasClauses: active.whereasClauses ?? [],
+ articles: this.normalizeArticles(active.articles ?? []),
+ };
+ }
+
+ private normalizeSnapshot(
+ input: ContractDocumentSnapshotInput,
+ ): ContractDocumentSnapshot {
+ return {
+ code: input.code ?? null,
+ name: input.name ?? null,
+ documentTitle: input.documentTitle ?? null,
+ whereasClauses: Array.isArray(input.whereasClauses)
+ ? input.whereasClauses
+ .map((c) => String(c))
+ .filter((c) => c.trim().length > 0)
+ : [],
+ articles: this.normalizeArticles(input.articles ?? []),
+ };
+ }
+
+ /** Re-key ids and renumber order sequentially, dropping empty-title rows. */
+ private normalizeArticles(
+ articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
+ ): ContractDocumentArticle[] {
+ return articles
+ .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
+ .map((a, index) => ({
+ id: a.id ?? randomUUID(),
+ title: (a.title ?? '').trim(),
+ body: a.body ?? '',
+ order: index + 1,
+ }));
+ }
+
+ /**
+ * The per-contract document may be edited/regenerated while the contract is at
+ * the accept stage (SUBMITTED) or in approval with NO approver having acted
+ * yet. The first approval action freezes it.
+ */
+ private documentIsEditable(contract: Contract): boolean {
+ if (contract.status === 'SUBMITTED') return true;
+ if (contract.status !== 'PENDING_APPROVAL') return false;
+ return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
+ }
+
+ private assertDocumentEditable(contract: Contract): void {
+ if (!this.documentIsEditable(contract)) {
+ throw new ConflictException(
+ 'The contract document is locked — an approver has already acted or the ' +
+ 'contract has advanced. It can no longer be edited or regenerated.',
+ );
+ }
+ }
+
/**
* Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing
@@ -303,6 +467,15 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
+ // Approvers review the generated contract document, so it must exist before
+ // the first approval can be recorded. Staff generate it (from the frozen,
+ // optionally-edited snapshot) at the accept stage.
+ if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
+ throw new BadRequestException(
+ 'Generate the contract document before it can be approved.',
+ );
+ }
+
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
@@ -350,15 +523,14 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
- // Final approval step also generates the contract document from the
- // template matching the contract's direction/freight pair. Best-effort:
- // a rendering hiccup must not roll back the approval — the document can
- // still be generated manually or lazily on view/download.
+ // Every step approved → CONTRACT_READY. The document was already generated
+ // (and reviewed) at the accept stage, so we reuse it rather than
+ // re-rendering. Best-effort: a hiccup must not roll back the approval.
try {
- return await this.generateContract(contractId);
+ return await this.finalizeApprovedContract(contractId);
} catch (err) {
this.logger.warn(
- `Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
+ `Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
);
}
}
@@ -366,30 +538,66 @@ export class ContractTransitionService {
}
/**
- * Render the contract PDF from the Contract aggregate, store it via FilesService,
- * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
- * Chromium) is best-effort and must NOT block the contract from becoming ready —
- * the document is (re)rendered lazily on view/download once Chromium is available.
+ * Staff (re)generate the contract PDF. Two stages:
+ * - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
+ * snapshot so approvers review the real document. Status is UNCHANGED, and
+ * it is blocked once an approver has acted (the document is then locked).
+ * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
+ * CONTRACT_READY.
+ * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
+ * transition — the document re-renders lazily on view/download.
*/
async generateContract(contractId: string): Promise {
const contract = await this.contractsService.findById(contractId);
+
+ if (contract.status === 'PENDING_APPROVAL') {
+ this.assertDocumentEditable(contract);
+ await this.renderContractDocument(contract);
+ return this.contractsService.findById(contractId);
+ }
+
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
+ await this.renderContractDocument(contract);
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
+ return this.contractsService.findById(contractId);
+ }
- const { view } = await this.documentViewModelBuilder.build(contractId);
-
+ /**
+ * Render the contract PDF from the Contract aggregate (snapshot-driven), store
+ * it via FilesService, and stamp the template key + generated timestamp. Never
+ * changes status. Rendering is best-effort — a Chromium hiccup defers the file
+ * (it re-renders on view/download) but the timestamp is still stamped.
+ */
+ private async renderContractDocument(contract: Contract): Promise {
+ const { view } = await this.documentViewModelBuilder.build(contract.id);
try {
- await this.upsertContractPdf(contractId, contract.reference, view);
+ await this.upsertContractPdf(contract.id, contract.reference, view);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
-
- await this.contractsRepository.update(contractId, {
- status: 'CONTRACT_READY',
+ await this.contractsRepository.update(contract.id, {
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
+ }
+
+ /**
+ * Every approval step landed → CONTRACT_READY. The document was already
+ * generated (and reviewed) at the accept stage, so reuse it; render now only
+ * if it was somehow never generated. Never re-renders over an existing file.
+ */
+ private async finalizeApprovedContract(contractId: string): Promise {
+ const contract = await this.contractsService.findById(contractId);
+ if (!contract.contractGeneratedAt) {
+ await this.renderContractDocument(contract);
+ }
+ await this.contractsRepository.update(contractId, {
+ status: 'CONTRACT_READY',
+ } as never);
return this.contractsService.findById(contractId);
}
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 2b79d3274..2957124f8 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -8,6 +8,7 @@ import {
ParseUUIDPipe,
Patch,
Post,
+ Put,
Query,
Res,
UnauthorizedException,
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
+import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
ApproveStepDto,
RejectContractDto,
@@ -340,9 +342,33 @@ export class ContractsController {
id,
resolveAuthUserId(user),
dto.validityDays,
+ dto.documentSnapshot,
);
}
+ @Get(':id/document/draft')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
+ })
+ getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
+ return this.transitionService.getContractDocumentDraft(id);
+ }
+
+ @Put(':id/document/articles')
+ @BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
+ @ApiOperation({
+ summary:
+ 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
+ })
+ updateContractDocument(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: UpdateContractDocumentDto,
+ ) {
+ return this.transitionService.updateContractDocument(id, dto);
+ }
+
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
index 86e1260c4..d3eaa73a3 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts
@@ -1,5 +1,8 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsInt, Max, Min } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
+
+import { UpdateContractDocumentDto } from './contract-document.dto';
export class AcceptContractDto {
@ApiProperty({
@@ -14,4 +17,16 @@ export class AcceptContractDto {
@Min(1)
@Max(3650)
validityDays!: number;
+
+ /**
+ * Optional per-contract document override edited by staff in the accept
+ * dialog. When present its articles are frozen onto THIS contract; when
+ * omitted the live template is snapshotted as-is. Never edits the shared
+ * six templates.
+ */
+ @ApiPropertyOptional({ type: UpdateContractDocumentDto })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdateContractDocumentDto)
+ documentSnapshot?: UpdateContractDocumentDto;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
new file mode 100644
index 000000000..7fdb8477e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts
@@ -0,0 +1,64 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsArray,
+ IsInt,
+ IsOptional,
+ IsString,
+ ValidateNested,
+} from 'class-validator';
+
+/** One article of a per-contract document override sent from the editor. */
+export class ContractDocumentArticleDto {
+ @ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
+ @IsOptional()
+ @IsString()
+ id?: string;
+
+ @ApiProperty()
+ @IsString()
+ title!: string;
+
+ @ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
+ @IsString()
+ body!: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsInt()
+ order?: number;
+}
+
+/**
+ * The per-contract document override sent from the accept/edit editor. It edits
+ * ONLY this contract's frozen snapshot — it is never written back to the shared
+ * six {@link ContractTemplate} rows.
+ */
+export class UpdateContractDocumentDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ code?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ name?: string | null;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ documentTitle?: string | null;
+
+ @ApiPropertyOptional({ type: [String] })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ whereasClauses?: string[];
+
+ @ApiProperty({ type: [ContractDocumentArticleDto] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => ContractDocumentArticleDto)
+ articles!: ContractDocumentArticleDto[];
+}
diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
index 0b0fab41b..f29b9093b 100644
--- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
+++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts
@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
+/** One article on a per-contract document snapshot (mirrors the template shape). */
+export interface ContractDocumentArticle {
+ id: string;
+ title: string;
+ body: string;
+ order: number;
+}
+
+/**
+ * A per-contract copy of the resolved contract-document template, frozen when
+ * staff accept the contract for approval. Staff may edit these articles for a
+ * single contract in the accept/edit dialog — editing NEVER writes back to the
+ * shared six {@link ContractTemplate} rows. The PDF is rendered from this
+ * snapshot when present; a null snapshot renders from the live template.
+ */
+export interface ContractDocumentSnapshot {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses: string[];
+ articles: ContractDocumentArticle[];
+}
+
+/** Loose inbound shape (article ids/order optional) — normalized before store. */
+export interface ContractDocumentSnapshotInput {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses?: string[];
+ articles?: Array<{
+ id?: string;
+ title?: string;
+ body?: string;
+ order?: number;
+ }>;
+}
+
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
+ /**
+ * Per-contract frozen copy of the document template (articles + WHEREAS),
+ * captured at staff accept. Editing it affects only this contract, never the
+ * shared six templates. Null → the PDF renders from the live template.
+ */
+ @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
+ documentSnapshot?: ContractDocumentSnapshot | null;
+
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
new file mode 100644
index 000000000..73ff94d31
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts
@@ -0,0 +1,72 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
+import { CurrentUser } from '@edr/api-common';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+
+import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
+import {
+ DecidePriorityRuleChangeDto,
+ SubmitPriorityRuleChangeDto,
+} from '../dto/priority-rule-change-request.dto';
+import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
+import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
+
+/**
+ * Approval workflow for priority-rule changes. Anyone with the manage
+ * permission SUBMITS a change; an approver (same permission — the team decides
+ * who reviews) approves or rejects it. The team is notified at each step.
+ */
+@ApiTags('priority-rule-change-requests')
+@Controller('priority-rule-change-requests')
+@ApiBearerAuth()
+export class PriorityRuleChangeRequestsController {
+ constructor(private readonly service: PriorityRuleChangeRequestsService) {}
+
+ @Post()
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Submit a priority-rule change for approval' })
+ submit(
+ @Body() dto: SubmitPriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.submit(dto, user?.id);
+ }
+
+ @Get()
+ @RuleEngineView('priority-configs')
+ @ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
+ @ApiOperation({ summary: 'List priority-rule change requests' })
+ list(@Query('status') status?: PriorityRuleChangeStatus) {
+ return this.service.list(status);
+ }
+
+ @Post(':id/approve')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Approve and apply a pending change' })
+ approve(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.approve(id, user?.id, dto.decisionNote);
+ }
+
+ @Post(':id/reject')
+ @RuleEngineManage('priority-configs')
+ @ApiOperation({ summary: 'Reject a pending change' })
+ reject(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: DecidePriorityRuleChangeDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.reject(id, user?.id, dto.decisionNote);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
new file mode 100644
index 000000000..31295b050
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts
@@ -0,0 +1,49 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ IsIn,
+ IsOptional,
+ IsString,
+ IsUUID,
+ MaxLength,
+ ValidateNested,
+} from 'class-validator';
+
+import { CreatePriorityConfigDto } from './create-priority-config.dto';
+import { UpdatePriorityConfigDto } from './update-priority-config.dto';
+
+/**
+ * File a priority-rule change for approval. CREATE carries a full `create`
+ * payload; UPDATE carries the target id + an `update` patch; DELETE carries
+ * only the target id.
+ */
+export class SubmitPriorityRuleChangeDto {
+ @ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
+ @IsIn(['CREATE', 'UPDATE', 'DELETE'])
+ action!: 'CREATE' | 'UPDATE' | 'DELETE';
+
+ @ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
+ @IsOptional()
+ @IsUUID()
+ priorityConfigId?: string;
+
+ @ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => CreatePriorityConfigDto)
+ create?: CreatePriorityConfigDto;
+
+ @ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => UpdatePriorityConfigDto)
+ update?: UpdatePriorityConfigDto;
+}
+
+export class DecidePriorityRuleChangeDto {
+ @ApiPropertyOptional({ description: 'Optional note shown to the requester' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(1000)
+ decisionNote?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
new file mode 100644
index 000000000..ec2425745
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts
@@ -0,0 +1,46 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+
+import { PriorityConfig } from './priority-config.entity';
+
+export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
+export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
+
+/**
+ * One proposed change to a priority rule, awaiting approval. Every
+ * create/update/delete of a priority config is filed here first; an approver
+ * applies (which runs the real mutation, including range-collision checks) or
+ * rejects it. `payload` holds the proposed field values (null for DELETE);
+ * `priorityConfigId` the target rule (null for CREATE).
+ */
+@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
+@Index(['status'])
+export class PriorityRuleChangeRequest extends BaseEntity {
+ @Column({ name: 'action', type: 'varchar', length: 10 })
+ action!: PriorityRuleChangeAction;
+
+ @Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
+ priorityConfigId?: string | null;
+
+ @ManyToOne(() => PriorityConfig, { nullable: true })
+ @JoinColumn({ name: 'priority_config_id' })
+ priorityConfig?: PriorityConfig | null;
+
+ @Column({ name: 'payload', type: 'jsonb', nullable: true })
+ payload?: Record | null;
+
+ @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
+ status!: PriorityRuleChangeStatus;
+
+ @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
+ requestedByUserId?: string | null;
+
+ @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
+ decidedByUserId?: string | null;
+
+ @Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
+ decidedAt?: Date | null;
+
+ @Column({ name: 'decision_note', type: 'text', nullable: true })
+ decisionNote?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts
index 34dc9f982..7edcf0bbf 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
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
+import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
+import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
+import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
+import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
+
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
+ PriorityRuleChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep,
BookingRateSnapshot,
]),
+ // Team notifications for the priority-rule approval workflow.
+ NotificationInboxModule,
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
+ PriorityRuleChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
+ PriorityRuleChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
index 560a550b2..9aaf06985 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise {
this.validateCurrencyField(dto.type, dto.currency);
+ await this.assertNoRangeCollision({
+ type: dto.type,
+ currency: dto.currency ?? null,
+ minWagonCount: dto.minWagonCount,
+ maxWagonCount: dto.maxWagonCount,
+ });
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, currency);
+ await this.assertNoRangeCollision({
+ type,
+ currency: currency ?? null,
+ minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: id,
+ });
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated;
}
+ /**
+ * No two rules of the same type (and, for CURRENCY rules, the same currency)
+ * may cover overlapping wagon-count ranges — a booking must match at most one
+ * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
+ * overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
+ */
+ async assertNoRangeCollision(input: {
+ type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
+ currency?: string | null;
+ minWagonCount: number;
+ maxWagonCount: number;
+ excludeId?: string;
+ }): Promise {
+ if (input.minWagonCount > input.maxWagonCount) {
+ throw new BadRequestException(
+ 'Min wagon count cannot be greater than max wagon count',
+ );
+ }
+ const siblings = await this.repository.findAll({
+ where: { type: input.type },
+ });
+ const clash = siblings.find(
+ (s) =>
+ s.id !== input.excludeId &&
+ (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
+ input.minWagonCount <= s.maxWagonCount &&
+ input.maxWagonCount >= s.minWagonCount,
+ );
+ if (clash) {
+ throw new BadRequestException(
+ `Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
+ `"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
+ 'Adjust the range so rules do not collide.',
+ );
+ }
+ }
+
async remove(id: string): Promise {
await this.findById(id);
await this.repository.softDelete(id);
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
new file mode 100644
index 000000000..bdc6a2e8f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts
@@ -0,0 +1,226 @@
+import {
+ NotificationAudience,
+ NotificationType,
+} from '@edr/types';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ Logger,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
+import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
+import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
+import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
+import {
+ PriorityRuleChangeRequest,
+ PriorityRuleChangeStatus,
+} from '../entities/priority-rule-change-request.entity';
+import { PriorityConfigsService } from './priority-configs.service';
+
+/** Backoffice rule-engine page — where both queue and rules live. */
+const RULES_LINK = '/dashboard/rules/priority-configs';
+
+/**
+ * Approval workflow for priority-rule changes. Nobody mutates priority configs
+ * directly any more: a change is SUBMITTED here (validated up front so the
+ * requester gets immediate feedback on range collisions), the team is
+ * notified, and an approver later applies or rejects it. Applying re-runs the
+ * full validation — the winning state is whatever is true at approval time.
+ */
+@Injectable()
+export class PriorityRuleChangeRequestsService {
+ private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
+
+ constructor(
+ @InjectRepository(PriorityRuleChangeRequest)
+ private readonly repo: Repository,
+ private readonly configs: PriorityConfigsService,
+ private readonly inbox: NotificationInboxService,
+ ) {}
+
+ async submit(
+ dto: SubmitPriorityRuleChangeDto,
+ userId?: string | null,
+ ): Promise {
+ const payload = await this.validateSubmission(dto);
+
+ const request = await this.repo.save(
+ this.repo.create({
+ action: dto.action,
+ priorityConfigId: dto.priorityConfigId ?? null,
+ payload,
+ status: 'PENDING',
+ requestedByUserId: userId ?? null,
+ }),
+ );
+
+ this.notifyTeam(
+ 'Priority rule change submitted',
+ `A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
+ request,
+ );
+ return request;
+ }
+
+ async list(status?: PriorityRuleChangeStatus): Promise {
+ return this.repo.find({
+ where: status ? { status } : {},
+ relations: { priorityConfig: true },
+ order: { createdAt: 'DESC' },
+ });
+ }
+
+ async approve(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+
+ // Apply the change through the normal service so currency + range-collision
+ // validation runs against the CURRENT rules; a stale request that now
+ // collides fails here and stays PENDING for the approver to see the error.
+ if (request.action === 'CREATE') {
+ await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
+ } else if (request.action === 'UPDATE') {
+ await this.configs.update(
+ this.requireTarget(request),
+ request.payload as unknown as UpdatePriorityConfigDto,
+ );
+ } else {
+ await this.configs.remove(this.requireTarget(request));
+ }
+
+ request.status = 'APPROVED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change approved',
+ `The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ async reject(
+ id: string,
+ userId?: string | null,
+ decisionNote?: string,
+ ): Promise {
+ const request = await this.findPending(id);
+ request.status = 'REJECTED';
+ request.decidedByUserId = userId ?? null;
+ request.decidedAt = new Date();
+ request.decisionNote = decisionNote ?? null;
+ const saved = await this.repo.save(request);
+
+ this.notifyTeam(
+ 'Priority rule change rejected',
+ `The ${request.action.toLowerCase()} priority-rule change was rejected.` +
+ (decisionNote ? ` Note: ${decisionNote}` : ''),
+ saved,
+ );
+ return saved;
+ }
+
+ /**
+ * Validate a submission the way applying it would, so bad requests are
+ * refused at the door — most importantly the wagon-range collision rule.
+ * Returns the payload to persist.
+ */
+ private async validateSubmission(
+ dto: SubmitPriorityRuleChangeDto,
+ ): Promise | null> {
+ if (dto.action === 'CREATE') {
+ if (!dto.create) {
+ throw new BadRequestException('CREATE requires the proposed rule in `create`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.create.type,
+ currency: dto.create.currency ?? null,
+ minWagonCount: dto.create.minWagonCount,
+ maxWagonCount: dto.create.maxWagonCount,
+ });
+ return { ...dto.create };
+ }
+
+ if (!dto.priorityConfigId) {
+ throw new BadRequestException(`${dto.action} requires priorityConfigId`);
+ }
+ const existing = await this.configs.findById(dto.priorityConfigId);
+
+ if (dto.action === 'DELETE') return null;
+
+ if (!dto.update || Object.keys(dto.update).length === 0) {
+ throw new BadRequestException('UPDATE requires the field changes in `update`');
+ }
+ await this.configs.assertNoRangeCollision({
+ type: dto.update.type ?? existing.type,
+ currency:
+ dto.update.currency !== undefined ? dto.update.currency : existing.currency,
+ minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
+ maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
+ excludeId: existing.id,
+ });
+ return { ...dto.update };
+ }
+
+ private async findPending(id: string): Promise {
+ const request = await this.repo.findOne({
+ where: { id },
+ relations: { priorityConfig: true },
+ });
+ if (!request) throw new NotFoundException(`Change request ${id} not found`);
+ if (request.status !== 'PENDING') {
+ throw new ConflictException(
+ `Change request is already ${request.status.toLowerCase()}`,
+ );
+ }
+ return request;
+ }
+
+ private requireTarget(request: PriorityRuleChangeRequest): string {
+ if (!request.priorityConfigId) {
+ throw new BadRequestException(
+ `${request.action} change request has no target rule`,
+ );
+ }
+ return request.priorityConfigId;
+ }
+
+ /**
+ * In-app notification to the whole backoffice team (submission AND decision
+ * both notify the team; the requester is staff, so they are included).
+ * Fire-and-forget — a notification failure never blocks the workflow.
+ */
+ private notifyTeam(
+ title: string,
+ body: string,
+ request: PriorityRuleChangeRequest,
+ ): void {
+ void this.inbox
+ .notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.REQUEST_SUBMITTED,
+ title,
+ body,
+ link: RULES_LINK,
+ data: { priorityRuleChangeRequestId: request.id, action: request.action },
+ })
+ .catch((err) =>
+ this.logger.warn(
+ `Priority-rule notification failed: ${(err as Error).message}`,
+ ),
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
index a3af6f572..165a0db20 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
@IsString()
cargoTypeCode?: string;
+ @ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
+ @IsOptional()
+ @IsUUID()
+ cargoTypeId?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
+ })
+ @IsOptional()
+ @Transform(({ value }) => {
+ if (value == null || value === '') return undefined;
+ if (typeof value !== 'string') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ })
+ @IsArray()
+ containerTypeIds?: string[];
+
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index c6e22589f..e4efaab9a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
originYardId: query.originYardId,
destinationYardId: query.destinationYardId,
freightType: query.freightType,
+ cargoTypeId: query.cargoTypeId,
cargoTypeCode: query.cargoTypeCode,
totalWeightTons: query.totalWeightTons,
containers: query.containers,
+ containerTypeIds: query.containerTypeIds,
});
}
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 26de90298..8c2742b64 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
@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
- await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
-
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
- await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
- const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
+ const slots = schedule.trainSet?.wagons ?? [];
+ const slotIds = new Set(slots.map((w) => w.id));
+ const slotById = new Map(slots.map((w) => [w.id, w]));
+ const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
+ // Occupancy is judged against THIS schedule's own slots only — a wagon
+ // pinned on another schedule (e.g. the same train's July 17 run) stays
+ // pinnable here.
+ const slotIdByPhysicalId = new Map(
+ slots
+ .filter((w) => w.physicalWagonId)
+ .map((w) => [w.physicalWagonId as string, w.id]),
+ );
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
- if (
- physicalWagon.status !== WagonStatus.Available &&
- physicalWagon.currentTrainScheduleId !== scheduleId
- ) {
+ const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
+ if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
+ const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is not available`,
+ `Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
- if (physicalWagon.currentYardId !== schedule.originStationId) {
- throw new ConflictException(
- `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
- );
+ if (builtTrainId) {
+ // Train-bound schedule: only the built train's own consist may be
+ // pinned — wherever the wagons currently sit, they travel with the
+ // train, so no yard/status gate applies.
+ if (physicalWagon.trainId !== builtTrainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
+ );
+ }
+ } else {
+ if (physicalWagon.trainId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
+ );
+ }
+ if (!this.isWagonPhysicallyUsable(physicalWagon)) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
+ );
+ }
+ if (
+ physicalWagon.currentTrainScheduleId &&
+ physicalWagon.currentTrainScheduleId !== scheduleId
+ ) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
+ );
+ }
+ if (physicalWagon.currentYardId !== schedule.originStationId) {
+ throw new ConflictException(
+ `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
+ );
+ }
}
+ // The pin lives ONLY on the schedule's slot — the Wagon entity keeps
+ // its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
- trainSetWagonId: assignment.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
+ for (const [physicalId, slotId] of slotIdByPhysicalId) {
+ if (slotId === assignment.trainSetWagonId) {
+ slotIdByPhysicalId.delete(physicalId);
+ break;
+ }
+ }
+ slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
+ // Same rule for wagons: many schedules may pin the same wagon, but it can
+ // only be OUT on one dispatched train at a time.
+ const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
+ .map((slot) => slot.physicalWagonId)
+ .filter((id): id is string => Boolean(id));
+ if (pinnedPhysicalIds.length) {
+ const rolling = await this.dataSource.getRepository(Wagon).find({
+ where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
+ });
+ const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
+ if (busy.length) {
+ throw new ConflictException(
+ `Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
+ );
+ }
+ }
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
originYardId: string,
targetScheduleId?: string,
): Promise> {
- const [wagons, wagonTypes, builtTrainId] = await Promise.all([
+ const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map();
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
- const pinnedOnTarget = targetScheduleId
- ? wagon.currentTrainScheduleId === targetScheduleId
- : false;
- if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
+ // Schedule-scoped availability: pins held by OTHER schedules never
+ // consume a wagon here — the same physical wagon may serve the July 17
+ // and the July 20 run. A wagon is unusable only when it is coupled to a
+ // built train's consist, physically blocked, or out on a dispatched
+ // train right now.
+ const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
+ if (wagon.trainId) continue;
+ if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== targetScheduleId
+ ) {
+ continue;
+ }
if (wagon.currentYardId !== originYardId) continue;
}
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
};
}
- private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
- const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
- const physicalIds = slots
- .map((slot) => slot.physicalWagonId)
- .filter((id): id is string => Boolean(id));
- if (!physicalIds.length) return;
- const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
- for (const wagon of wagons) {
- await manager.getRepository(Wagon).update(wagon.id, {
- // Built-train wagons stay coupled to their train (ASSIGNED); loose
- // wagons return to the open AVAILABLE pool.
- status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
- trainSetWagonId: null,
- currentTrainScheduleId: null,
- });
- }
+ /**
+ * A wagon in a blocked physical state can never be planned or pinned.
+ * ASSIGNED no longer blocks: it only means the wagon is coupled to a built
+ * train or stamped by a live run — schedule-level occupancy is tracked on
+ * the schedule's own TrainSetWagon slots, never on the Wagon entity.
+ */
+ private isWagonPhysicallyUsable(wagon: Wagon): boolean {
+ return (
+ wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
+ );
+ }
+
+ /**
+ * Physical wagons already pinned to THIS schedule's slots. Availability is
+ * schedule-scoped: only a duplicate pin within the same schedule conflicts;
+ * pins held by other schedules of the same train are irrelevant.
+ */
+ private async pinnedPhysicalWagonIdsForSchedule(
+ scheduleId: string | undefined,
+ manager?: EntityManager,
+ ): Promise> {
+ if (!scheduleId) return new Set();
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.id = $1
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ [scheduleId],
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
+ }
+
+ /**
+ * Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
+ * schedule. Used to guard consist trims — the Wagon entity itself carries no
+ * schedule-occupancy state anymore.
+ */
+ private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> {
+ const runner = manager ?? this.dataSource;
+ const rows: { physical_wagon_id: string }[] = await runner.query(
+ `SELECT DISTINCT tsw.physical_wagon_id
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ AND tsw.physical_wagon_id IS NOT NULL`,
+ );
+ return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
+ const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
+ scheduleId,
+ manager,
+ );
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) continue;
+ // Pin lives ONLY on the schedule's own slot — the Wagon entity is never
+ // touched here, so the same physical wagon stays free for every other
+ // schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
- await manager.getRepository(Wagon).update(physical.id, {
- trainSetWagonId: slot.trainSetWagonId,
- currentTrainScheduleId: scheduleId,
- status: WagonStatus.Assigned,
- });
assignedPhysicalIds.add(physical.id);
}
}
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
): Promise {
if (!wagonPlan.length) return [];
- const [wagons, builtTrainId] = await Promise.all([
+ const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
+ this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
targetScheduleId,
originYardId,
builtTrainId,
+ pinnedToScheduleIds,
);
}
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set();
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
originYardId,
assignedPhysicalIds,
builtTrainId,
+ pinnedToScheduleIds,
);
if (!physical) {
violations.push(
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
originYardId: string,
assignedPhysicalIds: Set,
builtTrainId: string | null = null,
+ pinnedToScheduleIds: Set = new Set(),
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
- const pinnedOnSchedule = scheduleId
- ? wagon.currentTrainScheduleId === scheduleId
- : false;
- return wagon.status === WagonStatus.Available || pinnedOnSchedule;
+ // Loose pool never lends a wagon coupled to a built train's consist.
+ if (wagon.trainId) return false;
+ // Out on a dispatched train right now — physically gone.
+ if (
+ wagon.currentTrainScheduleId &&
+ wagon.currentTrainScheduleId !== scheduleId
+ ) {
+ return false;
+ }
+ const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
+ return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
- // Free = not pinned to any run; only free wagons can be trimmed.
- removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
+ // Free = not pinned to any live run's slot; only free wagons can be trimmed.
+ removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
+ const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
- if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
+ if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
- * train capacity (not fully allocated). Wagon availability is deliberately NOT
- * checked here: whether a matching wagon currently sits in the right yard is an
- * operational question staff resolve when they approve or reject the booking,
- * not something the customer can act on while choosing a date. Same
+ * train capacity (not fully allocated) AND its wagon stock can physically carry
+ * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
+ * NOT gated — a booking bigger than the free capacity is accepted and the batch
+ * engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
+ const withCapacity = schedules.filter(
+ (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
+ );
+ const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
+
const days = new Set();
- for (const s of schedules) {
- const hasCapacity =
- Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
- if (!hasCapacity) continue;
+ for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
+ /**
+ * Wagon-TYPE compatibility gate (customer booking): keep only the schedules
+ * whose wagon stock can physically carry the selected cargo — every container
+ * line (or the bulk cargo type) must map to at least one wagon type the
+ * schedule's stock actually has. Stock = the built train's own consist, or the
+ * origin yard's loose pool for schedules assembled from loose locomotives.
+ * QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
+ * a partial split offer from the batch engine later.
+ */
+ private async filterCargoCompatibleSchedules(
+ schedules: TrainSchedule[],
+ cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ },
+ ): Promise {
+ if (!schedules.length) return schedules;
+ const required = await this.requiredWagonTypeSets(cargo);
+ // No cargo identity supplied — nothing to gate on (legacy callers).
+ if (required === null) return schedules;
+
+ const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
+ return schedules.filter((s) => {
+ const stock = stockByScheduleId.get(s.id) ?? new Set();
+ return required.every((set) => {
+ for (const typeId of set) if (stock.has(typeId)) return true;
+ return false;
+ });
+ });
+ }
+
+ /**
+ * One Set of allowed wagon-type ids per required cargo dimension: per
+ * container line's type (or per container size when only sizes are known),
+ * or a single set for the bulk cargo type. `null` = no cargo identity given,
+ * skip gating. An EMPTY set means "nothing can carry this" (no wagon types
+ * configured) — the gate then blocks every schedule, mirroring the hard
+ * config violation scheduling raises for the same state.
+ */
+ private async requiredWagonTypeSets(cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ cargoTypeCode?: string | null;
+ containers?: Array<{ containerSize: string; quantity: number }>;
+ containerTypeIds?: string[];
+ }): Promise[] | null> {
+ if (cargo.freightType === 'CONTAINER') {
+ const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
+ if (typeIds.length) {
+ const rows: { container_type_id: string; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
+ [typeIds],
+ );
+ const byType = new Map>(typeIds.map((id) => [id, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
+ }
+ return [...byType.values()];
+ }
+ // Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
+ // ANY active container type of that size has a matching wagon type.
+ const sizes = [
+ ...new Set(
+ (cargo.containers ?? [])
+ .map((line) => parseInt(String(line.containerSize), 10))
+ .filter((n) => Number.isFinite(n) && n > 0),
+ ),
+ ];
+ if (!sizes.length) return null;
+ const rows: { size_ft: number; wagon_type_id: string | null }[] =
+ await this.dataSource.query(
+ `SELECT ct.size_ft, wt.id AS wagon_type_id
+ FROM freight.container_types ct
+ LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
+ AND (ct.is_active IS DISTINCT FROM false)`,
+ [sizes],
+ );
+ const bySize = new Map>(sizes.map((s) => [s, new Set()]));
+ for (const row of rows) {
+ if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
+ }
+ return [...bySize.values()];
+ }
+
+ if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
+ const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
+ `SELECT wt.id AS wagon_type_id
+ FROM freight.cargo_types c
+ LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
+ LEFT JOIN freight.wagon_types wt
+ ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
+ WHERE c.deleted_at IS NULL
+ AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
+ [cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
+ );
+ const set = new Set();
+ for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
+ return [set];
+ }
+
+ /**
+ * Wagon-type ids each schedule's stock can offer: the built train's own
+ * consist for train-bound schedules, the origin yard's loose usable pool
+ * otherwise. Batched — two queries for the whole schedule list.
+ */
+ private async scheduleWagonTypeStock(
+ schedules: TrainSchedule[],
+ ): Promise>> {
+ const builtTrainIds = [
+ ...new Set(
+ schedules
+ .map((s) => s.trainSet?.trainId)
+ .filter((id): id is string => Boolean(id)),
+ ),
+ ];
+ const looseOriginYardIds = [
+ ...new Set(
+ schedules
+ .filter((s) => !s.trainSet?.trainId)
+ .map((s) => s.originStationId)
+ .filter(Boolean),
+ ),
+ ];
+
+ const [trainRows, yardRows] = await Promise.all([
+ builtTrainIds.length
+ ? (this.dataSource.query(
+ `SELECT train_id, wagon_type_id
+ FROM freight.wagons
+ WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
+ GROUP BY train_id, wagon_type_id`,
+ [builtTrainIds],
+ ) as Promise<{ train_id: string; wagon_type_id: string }[]>)
+ : Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
+ looseOriginYardIds.length
+ ? (this.dataSource.query(
+ `SELECT current_yard_id, wagon_type_id
+ FROM freight.wagons
+ WHERE train_id IS NULL AND deleted_at IS NULL
+ AND status IN ('AVAILABLE', 'ASSIGNED')
+ AND current_yard_id = ANY($1::uuid[])
+ GROUP BY current_yard_id, wagon_type_id`,
+ [looseOriginYardIds],
+ ) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
+ : Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
+ ]);
+
+ const byTrain = new Map>();
+ for (const row of trainRows) {
+ const set = byTrain.get(row.train_id) ?? new Set();
+ set.add(row.wagon_type_id);
+ byTrain.set(row.train_id, set);
+ }
+ const byYard = new Map>();
+ for (const row of yardRows) {
+ const set = byYard.get(row.current_yard_id) ?? new Set();
+ set.add(row.wagon_type_id);
+ byYard.set(row.current_yard_id, set);
+ }
+
+ const result = new Map>();
+ for (const s of schedules) {
+ const trainId = s.trainSet?.trainId;
+ result.set(
+ s.id,
+ trainId
+ ? byTrain.get(trainId) ?? new Set()
+ : byYard.get(s.originStationId) ?? new Set(),
+ );
+ }
+ return result;
+ }
+
+ /**
+ * Booking-time gate for a chosen day: does the route have an OPEN departure
+ * that day at all, and can any of that day's departures physically carry the
+ * cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
+ * partial split offer instead).
+ */
+ async checkDayCargoCompatibility(
+ originYardId: string,
+ destinationYardId: string,
+ day: string,
+ cargo: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ containerTypeIds?: string[];
+ },
+ ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
+ const schedules = await this.getBookableScheduleEntities(
+ originYardId,
+ destinationYardId,
+ );
+ const onDay = schedules.filter(
+ (s) =>
+ s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
+ );
+ if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
+ const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
+ return { hasDeparture: true, hasCompatible: compatible.length > 0 };
+ }
+
/**
* Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
@@ -5553,6 +5893,7 @@ export class TrainSchedulingService {
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
+ maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
requiresLoadingConfirmation,
loadingConfirmed,
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 c875433fe..3b5ad16cd 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
@@ -399,7 +399,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
- if (wagon.currentTrainScheduleId) {
+ if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
@@ -426,7 +426,7 @@ export class TrainBuilderService {
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
- if (wagon.currentTrainScheduleId) {
+ if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
@@ -441,6 +441,29 @@ export class TrainBuilderService {
return this.getComposition(id);
}
+ /**
+ * Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
+ * not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
+ * DISPATCHED) schedule has it pinned to one of its slots.
+ */
+ private async isWagonPinnedToLiveSchedule(
+ manager: EntityManager,
+ wagonId: string,
+ ): Promise {
+ const rows: { exists: boolean }[] = await manager.query(
+ `SELECT TRUE AS exists
+ FROM freight.train_set_wagons tsw
+ JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
+ WHERE tsw.physical_wagon_id = $1
+ AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ AND ts.deleted_at IS NULL
+ AND tsw.deleted_at IS NULL
+ LIMIT 1`,
+ [wagonId],
+ );
+ return rows.length > 0;
+ }
+
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts
new file mode 100644
index 000000000..28d0ec418
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts
@@ -0,0 +1,13 @@
+import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
+
+/**
+ * OCC bulk accept-and-execute: the subset of PENDING request ids to execute
+ * now. Requests not listed (or that cannot be executed) stay PENDING.
+ */
+export class BulkFulfillTransferRequestsDto {
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayMaxSize(200)
+ @IsUUID('all', { each: true })
+ requestIds!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts
index 4dd9f0f75..e747b69f2 100644
--- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts
+++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts
@@ -1,10 +1,20 @@
-import { ApiPropertyOptional } from '@nestjs/swagger';
-import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+ IsInt,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Max,
+ MaxLength,
+ Min,
+} from 'class-validator';
/**
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
- * those at fulfilment.
+ * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
+ * type currently in the source yard, and a reason is mandatory.
*/
export class CreateTransferRequestDto {
@IsUUID()
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
@Max(1000)
quantity!: number;
+ @ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' })
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(2000)
+ reason!: string;
+
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
@IsOptional()
@IsString()
diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts
index 40005de26..c81b6c365 100644
--- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts
+++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts
@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
+
+ /**
+ * Why the wagons are needed — required for every new request and shown on
+ * the OCC queue. Nullable only for rows that predate the requirement.
+ */
+ @Column({ name: 'reason', type: 'text', nullable: true })
+ reason?: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
index 9f2d41416..b66fc3f9d 100644
--- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
+++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
@@ -15,7 +15,7 @@ export const WAGON_STATUSES = [
WagonStatus.ImportReady,
WagonStatus.ExportReady,
WagonStatus.Maintenance,
- WagonStatus.Retired,
+ WagonStatus.Detained,
] as const;
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
index 12fdaf27c..d6925b71b 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
@@ -19,6 +19,7 @@ import {
WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
+import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status);
}
+ // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
+ // — Express matches in declaration order, so they would otherwise be captured
+ // by the `:id` param route (and rejected by ParseUUIDPipe).
+ @Post('bulk-fulfill')
+ @WagonTransferFulfill()
+ @ApiOperation({
+ summary:
+ 'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
+ })
+ bulkFulfill(
+ @Body() dto: BulkFulfillTransferRequestsDto,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.service.bulkFulfill(dto.requestIds, user?.id);
+ }
+
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
index bf69d767d..068d4dc6d 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
@@ -1,4 +1,4 @@
-import { WagonTransferRequestStatus } from '@edr/types';
+import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
private readonly wagonsService: WagonsService,
) {}
- /** Record a PENDING request. Count-only — no wagons are picked here. */
+ /**
+ * Record a PENDING request. Count-only — no wagons are picked here, but the
+ * count is capped at the AVAILABLE wagons of that type currently sitting in
+ * the source yard: staff may only ask for wagons that are actually there to
+ * give. A reason is mandatory and is shown on the OCC queue.
+ */
async createRequest(
dto: CreateTransferRequestDto,
userId?: string | null,
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different',
);
}
+ const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
+ if (available < dto.quantity) {
+ throw new BadRequestException(
+ available === 0
+ ? 'No available wagons of this type in the source yard'
+ : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
+ );
+ }
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
quantity: dto.quantity,
status: WagonTransferRequestStatus.Pending,
requestedByUserId: userId ?? null,
+ reason: dto.reason,
note: dto.note ?? null,
});
const saved = await this.requestRepo.save(request);
return this.findById(saved.id);
}
+ /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
+ private countAvailable(yardId: string, wagonTypeId: string): Promise {
+ return this.wagonRepo.count({
+ where: {
+ currentYardId: yardId,
+ wagonTypeId,
+ status: WagonStatus.Available,
+ },
+ });
+ }
+
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
async listRequests(
status?: WagonTransferRequestStatus,
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
.join(', ')}`,
);
}
+ const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
+ if (notAvailable.length) {
+ throw new BadRequestException(
+ `These wagons are not available: ${notAvailable
+ .map((w) => w.wagonNumber)
+ .join(', ')}`,
+ );
+ }
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
// each stamped with this request's id so history can link them back).
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
return this.findById(id);
}
+ /**
+ * OCC accepts AND executes a subset of pending requests in one action. For
+ * each selected request the system auto-picks the required number of
+ * AVAILABLE wagons of the requested type from the source yard (lowest wagon
+ * number first) and runs the audited transfer. A request that cannot be
+ * executed — already decided, or not enough available wagons left after the
+ * ones processed before it — is SKIPPED and simply stays PENDING, visible to
+ * both teams; nothing is rolled back for the others.
+ */
+ async bulkFulfill(
+ requestIds: string[],
+ userId?: string | null,
+ ): Promise<{
+ fulfilled: WagonTransferRequest[];
+ skipped: Array<{ id: string; reason: string }>;
+ }> {
+ const fulfilled: WagonTransferRequest[] = [];
+ const skipped: Array<{ id: string; reason: string }> = [];
+
+ // Sequential on purpose: each executed transfer moves wagons out of the
+ // source yard, and the next request's auto-pick must see that new state.
+ for (const id of [...new Set(requestIds)]) {
+ const request = await this.requestRepo.findOne({ where: { id } });
+ if (!request) {
+ skipped.push({ id, reason: 'Request not found' });
+ continue;
+ }
+ if (request.status !== WagonTransferRequestStatus.Pending) {
+ skipped.push({
+ id,
+ reason: `Already ${request.status.toLowerCase()}`,
+ });
+ continue;
+ }
+ const wagons = await this.wagonRepo.find({
+ where: {
+ currentYardId: request.fromYardId,
+ wagonTypeId: request.wagonTypeId,
+ status: WagonStatus.Available,
+ },
+ order: { wagonNumber: 'ASC' },
+ take: request.quantity,
+ });
+ if (wagons.length < request.quantity) {
+ skipped.push({
+ id,
+ reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
+ });
+ continue;
+ }
+ await this.wagonsService.bulkTransfer(
+ { wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
+ userId,
+ { transferRequestId: request.id },
+ );
+ request.status = WagonTransferRequestStatus.Fulfilled;
+ request.fulfilledByUserId = userId ?? null;
+ request.fulfilledAt = new Date();
+ await this.requestRepo.save(request);
+ fulfilled.push(await this.findById(id));
+ }
+
+ return { fulfilled, skipped };
+ }
+
/**
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
* individual wagons they physically moved (linked back to their request when
diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts
index 41ca7facb..fd967cc8c 100644
--- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts
@@ -144,7 +144,7 @@ export class SchedulingReadFacade {
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE deleted_at IS NULL
- AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
+ AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE')
ORDER BY wagon_number ASC`,
);
}
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
index 0e9ab450e..a7187638e 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
@@ -1,19 +1,13 @@
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
-import {
- Anchor,
- Button,
- Modal,
- Select,
- Stack,
- Text,
- Textarea,
-} from "@mantine/core";
+import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
+ FilePen,
FileSignature,
MessageSquareWarning,
+ RefreshCw,
ShieldCheck,
Sparkles,
XCircle,
@@ -23,6 +17,7 @@ import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
+import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
@@ -54,8 +49,8 @@ export function ContractActionsToolbar({
const navigate = useNavigate();
const { status } = contract;
- const [acceptOpen, setAcceptOpen] = useState(false);
- const [validityDays, setValidityDays] = useState(null);
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
@@ -76,12 +71,6 @@ export function ContractActionsToolbar({
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
- // Default the selection to the first configured option when the dialog opens.
- useEffect(() => {
- if (acceptOpen && !validityDays && validityOptions.length > 0) {
- setValidityDays(validityOptions[0].value);
- }
- }, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
@@ -98,10 +87,16 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
- // Generation only becomes available once EVERY approval step is complete and
- // the contract reaches APPROVED. While any step is still pending the contract
- // stays in PENDING_APPROVAL, so this button does not appear after only the
- // first (line-staff) approval — the director step must land first.
+ // While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
+ // can edit this contract's articles and (re)generate its PDF. The first
+ // approval action locks the document.
+ const docLocked =
+ status !== "PENDING_APPROVAL" ||
+ (contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
+ const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
+ const documentGenerated = Boolean(contract.contractGeneratedAt);
+ // Legacy fallback: if a contract ever lands on APPROVED without a document
+ // (older flow), still offer a manual generate that moves it to CONTRACT_READY.
const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the
@@ -131,7 +126,10 @@ export function ContractActionsToolbar({
fullWidth
color="edr-green"
leftSection={ }
- onClick={() => setAcceptOpen(true)}
+ onClick={() => {
+ setEditorMode("accept");
+ setEditorOpen(true);
+ }}
>
Accept for approval
@@ -156,6 +154,43 @@ export function ContractActionsToolbar({
>
)}
+ {canEditGenerate && (
+ <>
+
+ {documentGenerated
+ ? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
+ : "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
+
+ }
+ onClick={() => {
+ setEditorMode("edit");
+ setEditorOpen(true);
+ }}
+ >
+ Edit contract articles
+
+
+ ) : (
+
+ )
+ }
+ loading={mutations.generateContract.isPending}
+ onClick={() => mutations.generateContract.mutate()}
+ >
+ {documentGenerated ? "Regenerate contract" : "Generate contract"}
+
+ >
+ )}
+
{needsManualGenerate && (
- {/* Accept — sets the contract validity window */}
- setAcceptOpen(false)}
- title="Accept contract for approval"
- centered
- >
-
-
- Pick the contract validity window, then start the approval chain.
-
- {validityOptions.length > 0 ? (
-
- ) : (
-
- {validityLoading
- ? "Loading validity periods…"
- : "No validity periods are configured yet. Add them under "}
- {!validityLoading && (
- {
- e.preventDefault();
- navigate("/dashboard/dropdown-settings");
- }}
- >
- Dropdown Settings
-
- )}
- {!validityLoading && "."}
-
- )}
- {
- const days = Number(validityDays);
- if (!days) return;
- mutations.staffAccept.mutate(days, {
- onSuccess: () => setAcceptOpen(false),
- });
- }}
- >
- Accept
-
-
-
+ {/* Accept / edit — review + optionally edit this contract's articles */}
+ setEditorOpen(false)}
+ contractId={contract.id}
+ mode={editorMode}
+ validityOptions={validityOptions}
+ validityLoading={validityLoading}
+ accepting={mutations.staffAccept.isPending}
+ saving={mutations.updateDocument.isPending}
+ onAccept={(days, snapshot) =>
+ mutations.staffAccept.mutate(
+ { validityDays: days, documentSnapshot: snapshot },
+ { onSuccess: () => setEditorOpen(false) },
+ )
+ }
+ onSaveEdit={(snapshot) =>
+ mutations.updateDocument.mutate(snapshot, {
+ onSuccess: () => setEditorOpen(false),
+ })
+ }
+ />
{/* Request changes */}
void;
+ contractId: string;
+ /**
+ * "accept" — shown from the Accept-for-approval action: pick a validity window
+ * and (optionally) edit the articles, then start the approval chain.
+ * "edit" — re-edit the frozen articles of an already-accepted contract before
+ * generating/regenerating its PDF.
+ */
+ mode: "accept" | "edit";
+ /** Validity options (accept mode only). */
+ validityOptions?: Array<{ value: string; label: string }>;
+ validityLoading?: boolean;
+ accepting?: boolean;
+ saving?: boolean;
+ onAccept?: (
+ validityDays: number,
+ snapshot: Freight.IContractDocumentSnapshot,
+ ) => void;
+ onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
+}
+
+/**
+ * Per-contract contract-document editor. Loads the resolved template (or this
+ * contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
+ * for THIS contract only — it never writes back to the shared six templates.
+ */
+export function ContractDocumentEditorModal({
+ opened,
+ onClose,
+ contractId,
+ mode,
+ validityOptions = [],
+ validityLoading = false,
+ accepting = false,
+ saving = false,
+ onAccept,
+ onSaveEdit,
+}: ContractDocumentEditorModalProps) {
+ const { data: draft, isLoading } = useQuery({
+ queryKey: ["contracts", contractId, "document-draft"],
+ queryFn: () => contractsService.getContractDocumentDraft(contractId),
+ enabled: opened && Boolean(contractId),
+ // Always refetch the current draft when the dialog opens.
+ staleTime: 0,
+ });
+
+ const [documentTitle, setDocumentTitle] = useState("");
+ const [whereasClauses, setWhereasClauses] = useState([]);
+ const [articles, setArticles] = useState([]);
+ const [validityDays, setValidityDays] = useState(null);
+
+ // Seed the editor from the loaded draft whenever the dialog (re)opens.
+ useEffect(() => {
+ if (!opened || !draft) return;
+ setDocumentTitle(draft.documentTitle ?? "");
+ setWhereasClauses(draft.whereasClauses ?? []);
+ setArticles(
+ (draft.articles ?? []).map((a) => ({
+ id: a.id || newArticleId(),
+ title: a.title,
+ body: a.body,
+ })),
+ );
+ }, [opened, draft]);
+
+ // Default validity to the first configured option (accept mode).
+ useEffect(() => {
+ if (mode === "accept" && !validityDays && validityOptions.length > 0) {
+ setValidityDays(validityOptions[0].value);
+ }
+ }, [mode, validityDays, validityOptions]);
+
+ const locked = mode === "edit" && Boolean(draft?.locked);
+
+ const moveArticle = (index: number, delta: number) => {
+ setArticles((prev) => {
+ const next = [...prev];
+ const target = index + delta;
+ if (target < 0 || target >= next.length) return prev;
+ [next[index], next[target]] = [next[target], next[index]];
+ return next;
+ });
+ };
+
+ const updateArticle = (id: string, patch: Partial) =>
+ setArticles((prev) =>
+ prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
+ );
+
+ const removeArticle = (id: string) =>
+ setArticles((prev) => prev.filter((a) => a.id !== id));
+
+ const addArticle = () =>
+ setArticles((prev) => [
+ ...prev,
+ { id: newArticleId(), title: "", body: "" },
+ ]);
+
+ const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
+ code: draft?.code ?? null,
+ name: draft?.name ?? null,
+ documentTitle: documentTitle.trim() || null,
+ whereasClauses: whereasClauses
+ .map((c) => c.trim())
+ .filter((c) => c.length > 0),
+ articles: articles
+ .filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
+ .map((a, index) => ({
+ id: a.id,
+ title: a.title.trim(),
+ body: a.body,
+ order: index + 1,
+ })),
+ });
+
+ const hasArticles = useMemo(
+ () => articles.some((a) => a.title.trim() || a.body.trim()),
+ [articles],
+ );
+
+ const submit = () => {
+ const snapshot = buildSnapshot();
+ if (mode === "accept") {
+ const days = Number(validityDays);
+ if (!days) return;
+ onAccept?.(days, snapshot);
+ } else {
+ onSaveEdit?.(snapshot);
+ }
+ };
+
+ const submitting = accepting || saving;
+ const canSubmit =
+ hasArticles &&
+ !locked &&
+ (mode === "edit" || Boolean(validityDays)) &&
+ !submitting;
+
+ return (
+
+
+
+ {mode === "accept"
+ ? "Review contract document & accept"
+ : "Edit contract document"}
+
+
+ }
+ >
+ {isLoading ? (
+
+
+
+ Loading document…
+
+
+ ) : (
+
+ : }
+ >
+ {locked
+ ? "This document is locked — an approver has already acted, so it can no longer be edited."
+ : "Edits apply to THIS contract only. The six shared templates are never changed."}
+
+
+ setDocumentTitle(e.currentTarget.value)}
+ disabled={locked}
+ />
+
+
+
+
+ WHEREAS recitals
+
+ }
+ disabled={locked}
+ onClick={() => setWhereasClauses((p) => [...p, ""])}
+ >
+ Add recital
+
+
+ {whereasClauses.length === 0 ? (
+
+ No recitals.
+
+ ) : (
+
+ {whereasClauses.map((clause, i) => (
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {articles.map((article, index) => (
+
+
+
+ Article {index + 1}
+
+
+
+ moveArticle(index, -1)}
+ >
+
+
+
+
+ moveArticle(index, 1)}
+ >
+
+
+
+
+ removeArticle(article.id)}
+ >
+
+
+
+
+
+
+
+ updateArticle(article.id, { title: e.currentTarget.value })
+ }
+ />
+
+
+ ))}
+
+ }
+ disabled={locked}
+ onClick={addArticle}
+ >
+ Add article
+
+
+
+
+
+ {mode === "accept" && (
+ <>
+ {validityOptions.length > 0 ? (
+
+ ) : (
+
+ {validityLoading
+ ? "Loading validity periods…"
+ : "No validity periods are configured yet. Add them under Dropdown Settings."}
+
+ )}
+ >
+ )}
+
+
+
+ Cancel
+
+
+ {mode === "accept"
+ ? "Accept & start approval"
+ : "Save document changes"}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx
index b2a34eadc..27f9bbccc 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx
@@ -40,7 +40,7 @@ export const formatFleetCell = (
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
- if (s === "RETIRED") return "gray";
+ if (s === "RETIRED" || s === "DETAINED") return "gray";
return "gray";
};
const color = getStatusColor(status);
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
index f17c74d9a..3a231fb80 100644
--- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
@@ -168,6 +168,11 @@ function HistoryPanel({ opened }: { opened: boolean }) {
+ {r.reason ? (
+
+ Reason: {r.reason}
+
+ ) : null}
))}
@@ -233,6 +238,8 @@ const WagonTransferRequestsModal = ({
const [tab, setTab] = useState("queue");
const [active, setActive] = useState(null);
const [picked, setPicked] = useState>(new Set());
+ // Bulk accept-and-execute: the subset of pending requests OCC ticked.
+ const [selected, setSelected] = useState>(new Set());
const { data: requests = [], isLoading } = useQuery({
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
@@ -256,6 +263,9 @@ const WagonTransferRequestsModal = ({
});
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
+ const bulkFulfill = useMutation(
+ api.wagonTransferRequests.bulkFulfill.mutationOptions(),
+ );
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
const showError = (err: unknown, fallback: string) => {
@@ -310,6 +320,36 @@ const WagonTransferRequestsModal = ({
}
};
+ const toggleSelected = (id: string) =>
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+
+ // Execute the ticked subset; whatever cannot run (not enough available
+ // wagons, already decided) is reported and simply stays PENDING.
+ const handleBulkFulfill = async () => {
+ if (selected.size === 0) return;
+ try {
+ const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
+ setSelected(new Set());
+ const skippedNote = res.skipped.length
+ ? ` · ${res.skipped.length} left pending (${res.skipped
+ .map((s) => s.reason)
+ .join('; ')})`
+ : "";
+ toast({
+ title: `Executed ${res.fulfilled.length} transfer request(s)`,
+ description: skippedNote || undefined,
+ variant: res.fulfilled.length === 0 ? "destructive" : undefined,
+ });
+ } catch (err) {
+ showError(err, "Bulk execute failed");
+ }
+ };
+
const sortedWagons = useMemo(
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
[wagons],
@@ -371,17 +411,65 @@ const WagonTransferRequestsModal = ({
) : (
+ {/* Bulk accept-and-execute action bar: tick a subset, run it, and
+ everything unticked (or unexecutable) stays PENDING. */}
+
+ 0
+ ? `${selected.size} of ${requests.length} selected`
+ : "Select all"
+ }
+ checked={selected.size === requests.length && requests.length > 0}
+ indeterminate={selected.size > 0 && selected.size < requests.length}
+ onChange={() =>
+ setSelected(
+ selected.size === requests.length
+ ? new Set()
+ : new Set(requests.map((r) => r.id)),
+ )
+ }
+ color="edr-green"
+ />
+ }
+ loading={bulkFulfill.isPending}
+ disabled={selected.size === 0}
+ onClick={handleBulkFulfill}
+ >
+ Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
+
+
+
{requests.map((r) => (
-
-
- {r.note ? (
-
- “{r.note}”
-
- ) : null}
-
+
+ toggleSelected(r.id)}
+ color="edr-green"
+ mt={2}
+ />
+
+
+ {r.reason ? (
+
+
+ Reason:
+ {" "}
+ {r.reason}
+
+ ) : null}
+ {r.note ? (
+
+ “{r.note}”
+
+ ) : null}
+
+
(null);
const [transferQty, setTransferQty] = useState(0);
+ const [transferReason, setTransferReason] = useState("");
const [toAssignedQty, setToAssignedQty] = useState(0);
const [toAvailableQty, setToAvailableQty] = useState(0);
@@ -207,6 +209,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
useEffect(() => {
setTransferYardId(null);
setTransferQty(0);
+ setTransferReason("");
setToAssignedQty(0);
setToAvailableQty(0);
}, [yardId, typeId]);
@@ -219,8 +222,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
}, [opened]);
- // Keep quantities within bounds as counts shift after each action.
- useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]);
+ // Keep quantities within bounds as counts shift after each action. Transfers
+ // may only ask for AVAILABLE wagons, so the request cap is availableCount.
+ useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
@@ -233,13 +237,21 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
// Request-only: the requester specifies count + destination; OCC later picks
// the physical wagons and executes the move. No wagons are moved here.
const handleRequest = async () => {
- if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
+ if (
+ !yardId ||
+ !typeId ||
+ !transferYardId ||
+ transferQty < 1 ||
+ !transferReason.trim()
+ )
+ return;
try {
await createRequest.mutateAsync({
fromYardId: yardId,
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
+ reason: transferReason.trim(),
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
@@ -249,6 +261,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
});
setTransferQty(0);
setTransferYardId(null);
+ setTransferReason("");
} catch (err) {
showError(err, "Request failed");
}
@@ -407,10 +420,19 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
-
- How many wagons
-
-
+
+
+ How many wagons
+
+
+ {availableCount} available
+
+
+
+
+ }
+ loading={approve.isPending}
+ onClick={() => approve.mutate({ id: r.id })}
+ >
+ Approve & apply
+
+
+ ) : null}
+
+
+ ))}
+
+
+ );
+};
+
+export default PriorityRuleApprovalsSection;
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index 81a485a3a..95c4c22ac 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -18,6 +18,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
+import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -34,6 +35,7 @@ import {
useContainerTypeOptions,
useLiveRateOptions,
useWagonTypeOptions,
+ usePriorityRuleWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -142,6 +144,13 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules",
);
+ // Priority rules never mutate directly: changes are filed for approval and a
+ // pending queue renders above the table.
+ const isPriorityRules = config?.slug === "priority-configs";
+ const priorityWorkflow = usePriorityRuleWorkflow(
+ Boolean(isPriorityRules && canView),
+ );
+
const editingId = editing?.id ? String(editing.id) : undefined;
const usesContainerTypeField = Boolean(
config?.formFields.some((f) => f.name === "containerTypeId"),
@@ -360,9 +369,37 @@ const RuleEngineResourcePage = () => {
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
- } else if (config.slug === "priority-configs") {
+ } else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
+ // Approval workflow: file a change request instead of mutating directly.
+ // On update, keep the target's existing label rather than a fresh stamp.
+ if (editing?.id) {
+ priorityWorkflow.submit.mutate(
+ {
+ action: "UPDATE",
+ priorityConfigId: String(editing.id),
+ update: { ...values, label: String(editing.label ?? Date.now()) },
+ },
+ {
+ onSuccess: () => {
+ setFormOpen(false);
+ setEditing(null);
+ },
+ },
+ );
+ } else {
+ priorityWorkflow.submit.mutate(
+ { action: "CREATE", create: payload },
+ {
+ onSuccess: () => {
+ setFormOpen(false);
+ setEditing(null);
+ },
+ },
+ );
+ }
+ return;
} else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it).
@@ -406,6 +443,15 @@ const RuleEngineResourcePage = () => {
}
/>
+ {isPriorityRules ? (
+
+ ) : null}
+
@@ -519,7 +565,9 @@ const RuleEngineResourcePage = () => {
}
fields={formFields}
initialRecord={editing}
- isSubmitting={create.isPending || update.isPending}
+ isSubmitting={
+ create.isPending || update.isPending || priorityWorkflow.submit.isPending
+ }
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(usesContainerTypeField && containerTypeOptionsLoading) ||
@@ -557,8 +605,9 @@ const RuleEngineResourcePage = () => {
>
- This will soft-delete the selected {config.label.toLowerCase()}{" "}
- record.
+ {isPriorityRules
+ ? "This files a delete request for approval — the rule is removed once an approver confirms."
+ : `This will soft-delete the selected ${config.label.toLowerCase()} record.`}
setDeleteTarget(null)}>
@@ -566,15 +615,25 @@ const RuleEngineResourcePage = () => {
{
if (!deleteTarget) return;
+ if (isPriorityRules) {
+ priorityWorkflow.submit.mutate(
+ {
+ action: "DELETE",
+ priorityConfigId: String(deleteTarget.id),
+ },
+ { onSuccess: () => setDeleteTarget(null) },
+ );
+ return;
+ }
remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
- Delete
+ {isPriorityRules ? "Request delete" : "Delete"}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index 671b351b7..33c4535ce 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -68,6 +68,7 @@ import {
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
+import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { openPdfBlob } from "@/components/warehouses/pdf";
@@ -252,12 +253,6 @@ export default function TrainScheduleV2DetailPage() {
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
[displayWagonPlan, isExportDisplay],
);
- const diagramWagons = useMemo(() => {
- const source = schedule?.trainSet?.wagons?.length
- ? schedule.trainSet.wagons
- : displayWagonPlan;
- return isExportDisplay ? [...source].reverse() : source;
- }, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
@@ -774,22 +769,26 @@ export default function TrainScheduleV2DetailPage() {
);
}
- // finalize
+ // finalize — the train is known here, so draw the full composition the
+ // same way the batch board's composition tab does (interactive consist).
return (
-
- {isExportDisplay && diagramWagons.length ? (
-
- Shown rear-first (export direction) — positions keep their original numbers.
-
- ) : null}
+ {schedule.trainSet ? (
+
+ ) : (
+
+ )}
[["wagonTransferRequests"], ["wagons"]],
),
+ bulkFulfill: endpoint<{ requestIds: string[] }, BulkFulfillResult>(
+ "wagonTransferRequests",
+ "bulkFulfill",
+ ({ requestIds }) =>
+ wagonTransferRequestService.bulkFulfill(requestIds).then((r) => r.data),
+ undefined,
+ () => [["wagonTransferRequests"], ["wagons"]],
+ ),
+
cancel: endpoint<{ id: string }, WagonTransferRequest>(
"wagonTransferRequests",
"cancel",
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index b3e2fbbd3..eb027dde7 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -170,8 +170,35 @@ export const contractsService = {
},
// ── Staff review ──
- staffAccept: (id: string, validityDays: number) =>
- postContract(C.STAFF_ACCEPT(id), { validityDays }),
+ staffAccept: (
+ id: string,
+ validityDays: number,
+ documentSnapshot?: Freight.IContractDocumentSnapshot,
+ ) =>
+ postContract(C.STAFF_ACCEPT(id), {
+ validityDays,
+ documentSnapshot,
+ }),
+
+ /** The editable per-contract document draft (snapshot or live template). */
+ getContractDocumentDraft: async (
+ id: string,
+ ): Promise => {
+ const response = await client.get(C.CONTRACT_DOCUMENT_DRAFT(id));
+ return unwrap(response.data) as Freight.IContractDocumentDraft;
+ },
+
+ /** Save this contract's edited document articles (never touches the templates). */
+ updateContractDocument: async (
+ id: string,
+ snapshot: Freight.IContractDocumentSnapshot,
+ ): Promise => {
+ const response = await client.put(
+ C.CONTRACT_DOCUMENT_ARTICLES(id),
+ snapshot,
+ );
+ return unwrap(response.data) as Freight.IContract;
+ },
requestChanges: (id: string, note: string) =>
postContract(C.STAFF_REQUEST_CHANGES(id), { note }),
diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
index a19896732..f8b47aff7 100644
--- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
@@ -24,6 +24,30 @@ export interface RuleEngineReorderPayload {
requiresDirectorApproval?: boolean;
}
+/** Priority-rule approval workflow (all priority-config changes go through it). */
+const PRIORITY_RULE_CHANGES_BASE = "/priority-rule-change-requests";
+
+export interface PriorityRuleChangeRequest {
+ id: string;
+ action: "CREATE" | "UPDATE" | "DELETE";
+ priorityConfigId: string | null;
+ priorityConfig?: RuleEngineRecord | null;
+ payload: Record | null;
+ status: "PENDING" | "APPROVED" | "REJECTED";
+ requestedByUserId: string | null;
+ decidedByUserId: string | null;
+ decidedAt: string | null;
+ decisionNote: string | null;
+ createdAt: string;
+}
+
+export interface SubmitPriorityRuleChangePayload {
+ action: "CREATE" | "UPDATE" | "DELETE";
+ priorityConfigId?: string;
+ create?: Record;
+ update?: Record;
+}
+
const RESOURCE_BASE: Record = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -242,6 +266,46 @@ export const ruleEngineService = {
return normalizeEntity(response.data);
},
+ /** File a priority-rule change (create/update/delete) for approval. */
+ submitPriorityRuleChange: async (
+ payload: SubmitPriorityRuleChangePayload,
+ ): Promise => {
+ const response = await client.post(PRIORITY_RULE_CHANGES_BASE, payload);
+ return unwrap(response.data) as PriorityRuleChangeRequest;
+ },
+
+ listPriorityRuleChanges: async (
+ status?: PriorityRuleChangeRequest["status"],
+ ): Promise => {
+ const response = await client.get(PRIORITY_RULE_CHANGES_BASE, {
+ params: status ? { status } : undefined,
+ });
+ const body = unwrap(response.data) as unknown;
+ return Array.isArray(body) ? (body as PriorityRuleChangeRequest[]) : [];
+ },
+
+ approvePriorityRuleChange: async (
+ id: string,
+ decisionNote?: string,
+ ): Promise => {
+ const response = await client.post(
+ `${PRIORITY_RULE_CHANGES_BASE}/${id}/approve`,
+ { decisionNote },
+ );
+ return unwrap(response.data) as PriorityRuleChangeRequest;
+ },
+
+ rejectPriorityRuleChange: async (
+ id: string,
+ decisionNote?: string,
+ ): Promise => {
+ const response = await client.post(
+ `${PRIORITY_RULE_CHANGES_BASE}/${id}/reject`,
+ { decisionNote },
+ );
+ return unwrap(response.data) as PriorityRuleChangeRequest;
+ },
+
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise => {
diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
index e818467f5..c7e1bf142 100644
--- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
@@ -108,6 +108,8 @@ export interface WagonTransferRequest {
requestedByUserId: string | null;
fulfilledByUserId: string | null;
fulfilledAt: string | null;
+ /** Why the wagons are needed — required for new requests, shown on the queue. */
+ reason?: string | null;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
@@ -120,9 +122,17 @@ export interface CreateTransferRequestPayload {
toYardId: string;
wagonTypeId: string;
quantity: number;
+ /** Mandatory: why the wagons are needed. */
+ reason: string;
note?: string;
}
+/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
+export interface BulkFulfillResult {
+ fulfilled: WagonTransferRequest[];
+ skipped: Array<{ id: string; reason: string }>;
+}
+
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
@@ -146,6 +156,11 @@ export const wagonTransferRequestService = {
apiClient.get(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
apiClient.post('/wagon-transfer-requests', data),
+ /** OCC: accept-and-execute a subset of pending requests (auto-picked wagons). */
+ bulkFulfill: (requestIds: string[]) =>
+ apiClient.post('/wagon-transfer-requests/bulk-fulfill', {
+ requestIds,
+ }),
/** OCC: execute the transfer with the hand-picked wagons. */
fulfill: (id: string, wagonIds: string[]) =>
apiClient.post(
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index a80c5a71d..b32aa42f9 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -528,6 +528,8 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
+ /** Wagon cap for this departure (built-train consist size or configured limit). */
+ maxWagons?: number | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: {
id: string;
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index afcd2f777..d3808c8ce 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -279,6 +279,60 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
+ const watchedCargoKind = form.watch("cargoType");
+ const watchedContainers = form.watch("containers");
+ const watchedCargoTypePath = form.watch("cargoTypePath");
+ const watchedScheduledDate = form.watch("scheduledDate");
+ const isGeneralContractBooking =
+ form.watch("bookingType") === "general_contract";
+
+ // Wagon-TYPE availability gate: which days have a departure whose train can
+ // physically carry the selected cargo/container type. Quantity is NOT part
+ // of this gate — an oversized booking is accepted and gets a partial split
+ // offer later. Only selectable days reach the UI; no capacity counts.
+ const gateContainerTypeIds = useMemo(() => {
+ if (watchedCargoKind !== "container") return [];
+ const groups = referenceData?.containers ?? [];
+ const ids = new Set();
+ for (const line of watchedContainers ?? []) {
+ if (!line?.containerType) continue;
+ for (const group of groups) {
+ const ct = group.types.find((t) => t.name === line.containerType);
+ if (ct) ids.add(ct.id);
+ }
+ }
+ return [...ids];
+ }, [watchedCargoKind, watchedContainers, referenceData]);
+ const gateCargoTypeId =
+ watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
+ const gateReady =
+ !isGeneralContractBooking &&
+ !!originYard &&
+ !!destinationYard &&
+ (watchedCargoKind === "bulk"
+ ? !!gateCargoTypeId
+ : gateContainerTypeIds.length > 0);
+ const availableDaysQuery = useQuery(
+ api.bookings.getAvailableDaysForCargo.queryOptions({
+ input: {
+ originYardId: originYard,
+ destinationYardId: destinationYard,
+ freightType: watchedCargoKind === "bulk" ? "BULK" : "CONTAINER",
+ cargoTypeId: gateCargoTypeId || undefined,
+ containerTypeIds: gateContainerTypeIds,
+ },
+ enabled: gateReady,
+ }),
+ );
+ const availableBookingDays = gateReady ? availableDaysQuery.data : undefined;
+ // Block submit only on a POSITIVE answer that the picked day has no wagon
+ // for this cargo — a loading/failed availability lookup never bricks the
+ // wizard (the backend re-checks at the binding step anyway).
+ const noWagonForSelectedDay = Boolean(
+ availableBookingDays &&
+ watchedScheduledDate &&
+ !availableBookingDays.includes(watchedScheduledDate),
+ );
// The estimated shipment date lives in the Route step now; for general
// contracts that date field is simply hidden there (the date is chosen per
@@ -678,6 +732,8 @@ export default function NewBookingPage() {
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
+ availableDays={availableBookingDays}
+ noWagonForSelectedDay={noWagonForSelectedDay}
/>
)}
{step === 6 && }
@@ -698,6 +754,7 @@ export default function NewBookingPage() {
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
+ noWagonForSelectedDay={noWagonForSelectedDay}
/>
)}
@@ -746,6 +803,7 @@ export default function NewBookingPage() {
leftSection={ }
onClick={handleSubmitBooking}
loading={isPricing}
+ disabled={noWagonForSelectedDay}
>
Submit
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
index 5d1af234d..4edfa73b7 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx
@@ -220,12 +220,14 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
Choose your shipment day
- Only days with a scheduled departure on your route can be selected.
- The operations team assigns the specific train for that day.
+ Only days with a scheduled departure that can carry your cargo type
+ can be selected. The operations team assigns the specific train for
+ that day.
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx
index e006355ea..307b9f8c5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx
@@ -6,6 +6,8 @@ import { api } from "@/services/api";
interface OperationDatePickerProps {
originYardId?: string;
destinationYardId?: string;
+ /** When set, days are cargo-aware: only days whose train can carry THIS booking's cargo type. */
+ bookingId?: string;
value: string;
onChange: (date: string) => void;
}
@@ -13,20 +15,31 @@ interface OperationDatePickerProps {
/**
* Route-based day picker for the operation-request step: a thin query wrapper
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
- * Only days with an OPEN scheduled departure on the route are selectable.
+ * Only days with an OPEN scheduled departure on the route are selectable; with
+ * a `bookingId` the server additionally drops days whose trains have no wagon
+ * type that can carry the booking's cargo (no capacity counts are shown).
*/
export function OperationDatePicker({
originYardId,
destinationYardId,
+ bookingId,
value,
onChange,
}: OperationDatePickerProps) {
- const { data: availableDays, isLoading } = useQuery(
+ const routeDays = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
- enabled: !!originYardId && !!destinationYardId,
+ enabled: !bookingId && !!originYardId && !!destinationYardId,
}),
);
+ const bookingDays = useQuery(
+ api.bookings.getAvailableDaysForBooking.queryOptions({
+ input: { bookingId: bookingId ?? "" },
+ enabled: !!bookingId,
+ }),
+ );
+ const availableDays = bookingId ? bookingDays.data : routeDays.data;
+ const isLoading = bookingId ? bookingDays.isLoading : routeDays.isLoading;
return (
}
error={fieldState.error?.message}
+ // Days whose trains cannot carry the selected cargo type are
+ // not selectable (wagon-TYPE gate; quantity never blocks).
+ excludeDate={(date) => {
+ if (!availableDays) return false;
+ const day =
+ typeof date === "string"
+ ? date.slice(0, 10)
+ : new Date(date).toISOString().slice(0, 10);
+ return !availableDays.includes(day);
+ }}
// Mantine v9 DatePickerInput uses string (YYYY-MM-DD) values,
// matching the form's `scheduledDate` string directly.
value={field.value || null}
@@ -204,6 +223,19 @@ export function Step4Route({
/>
)}
/>
+ {noWagonForSelectedDay && (
+
+ No wagon on this day's train can carry your cargo type —
+ please pick another available day.
+
+ )}
+ {availableDays && availableDays.length === 0 && (
+
+ No upcoming departure can carry this cargo type on the chosen
+ route right now. Try a different cargo/container type or check
+ back later.
+
+ )}
)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index 501a04a5d..f76608690 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -137,6 +137,7 @@ export function Step8Review({
onSubmit,
saveDraftPending = false,
submitPending = false,
+ noWagonForSelectedDay = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
@@ -147,6 +148,8 @@ export function Step8Review({
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
+ /** Wagon-TYPE gate: the picked day has no train that can carry this cargo. */
+ noWagonForSelectedDay?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
@@ -551,6 +554,19 @@ export function Step8Review({
+ ) : noWagonForSelectedDay ? (
+
+
+
+ No wagon available for the selected day
+
+
+ No train departing that day has a wagon type that can carry
+ your cargo. Go back to the route step and pick one of the
+ available days.
+
+
+
) : (
Ready to submit. You'll review the unit rates before final
@@ -567,7 +583,7 @@ export function Step8Review({
leftSection={ }
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending || hasOdd20ft}
+ disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
>
Submit
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 6f4527c0b..a82945ecf 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -423,6 +423,12 @@ export const api = {
bookingsService.getAvailableDaysForCargo(input),
),
+ getAvailableDaysForBooking: endpoint<{ bookingId: string }, string[]>(
+ "train-scheduling",
+ "availableDaysForBooking",
+ ({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
+ ),
+
getMyBookingWindows: endpoint(
"train-scheduling",
"myBookingWindows",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 88113c451..910ba89a0 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -414,25 +414,38 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
- // Cargo-aware day pool: only days where a train has remaining capacity AND
- // enough matching-type wagons for this cargo. `containers` is serialized as a
- // JSON string param (the server parses it).
+ // Cargo-aware day pool: only days where a train has remaining capacity AND a
+ // wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
+ // serialized as JSON string params (the server parses them). Days only — no
+ // capacity counts are ever returned.
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise => {
- const { containers, ...rest } = query;
+ const { containers, containerTypeIds, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
+ ...(containerTypeIds?.length
+ ? { containerTypeIds: JSON.stringify(containerTypeIds) }
+ : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
+ // Days bookable for an EXISTING booking (operation-request step): the server
+ // derives the cargo from the booking and applies the wagon-type gate.
+ getAvailableDaysForBooking: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/bookings/${bookingId}/available-days`,
+ );
+ return (data.data as Freight.AvailableDaysResponse).days;
+ },
+
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index 7d3962932..a0c51645f 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -182,6 +182,42 @@ export interface IContractSignature {
signedAt: string;
}
+// ── Per-contract document snapshot (staff-editable articles for one contract) ─
+
+export interface IContractDocumentArticle {
+ id: string;
+ title: string;
+ body: string;
+ order: number;
+}
+
+/**
+ * A per-contract frozen copy of the resolved document template. Staff may edit
+ * its articles for a single contract in the accept/edit dialog — this never
+ * writes back to the shared six contract templates. Null → the PDF renders from
+ * the live template.
+ */
+export interface IContractDocumentSnapshot {
+ code?: string | null;
+ name?: string | null;
+ documentTitle?: string | null;
+ whereasClauses: string[];
+ articles: IContractDocumentArticle[];
+}
+
+/** Editable document draft returned for the accept/edit editor. */
+export interface IContractDocumentDraft {
+ documentTitle: string | null;
+ whereasClauses: string[];
+ articles: IContractDocumentArticle[];
+ code: string | null;
+ name: string | null;
+ /** True once the document may no longer be edited/regenerated. */
+ locked: boolean;
+ generatedAt: string | null;
+ status: ContractStatus;
+}
+
export type ContractApprovalStepStatus =
| "PENDING"
| "APPROVED"
@@ -584,6 +620,8 @@ export interface IContract extends BaseEntity {
contractType?: string | null;
contractTemplateKey?: string | null;
contractGeneratedAt?: string | null;
+ /** Per-contract frozen document (articles + WHEREAS) captured at staff accept. */
+ documentSnapshot?: IContractDocumentSnapshot | null;
contractSummary?: string | null;
versionNumber: number;
financialTerms?: string | null;
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index bd6c8ffeb..9ed8b1a39 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -231,7 +231,8 @@ export enum WagonStatus {
ImportReady = "IMPORT_READY",
ExportReady = "EXPORT_READY",
Maintenance = "MAINTENANCE",
- Retired = "RETIRED",
+ /** Formerly RETIRED — wagons pulled from circulation. */
+ Detained = "DETAINED",
}
export enum WagonReadiness {
@@ -359,6 +360,8 @@ export interface IWagonTransferRequest extends BaseEntity {
requestedByUserId?: string | null;
fulfilledByUserId?: string | null;
fulfilledAt?: string | null;
+ /** Why the wagons are needed — required for new requests, shown on the OCC queue. */
+ reason?: string | null;
note?: string | null;
}
@@ -923,10 +926,14 @@ export interface AvailableDaysForCargoQuery {
freightType: "CONTAINER" | "BULK";
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
cargoTypeCode?: string;
+ /** Bulk cargo type id — preferred over code for the wagon-type gate. */
+ cargoTypeId?: string;
/** Total bulk weight in tons. */
totalWeightTons?: number;
/** Container lines (size + quantity) for container freight. */
containers?: { containerSize: string; quantity: number }[];
+ /** Container type ids — enables the exact wagon-type compatibility gate. */
+ containerTypeIds?: string[];
}
/**
From fc4aadbc5733d3f67dc2d28bc435a339283b7eec Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 13:51:23 +0000
Subject: [PATCH 03/11] =?UTF-8?q?feat(warehouse):=20P1=20dashboard=20analy?=
=?UTF-8?q?tics=20=E2=80=94=20dwell/aging,=20cycle-time=20+=20on-time,=20l?=
=?UTF-8?q?ive=20deltas?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the operational-performance layer to the warehouse cockpit:
- Dwell time & aging: dwellStats() (avg + 0-3/4-7/8-14/15+ buckets over
in-warehouse items) → DwellAgingCard histogram.
- Cycle time & on-time: cycleStats() (arrived→ready→loaded→dispatched stage
averages + dock-to-dispatch) and onTimeDispatchStats() (share of items that
left before their storage free-days expired, resolved via the fee engine's
own rule matching) → CycleTimeCard.
- Live tiles: opsStats() now returns receivedYesterday; KpiStrip renders an
optional ▲/▼ delta, and the ops strip shows received-today vs yesterday.
New endpoints: GET /warehouse-inventory/{dwell-stats,cycle-stats} and
/warehouse-fees/on-time-dispatch (all guarded). New "Performance" section on
WarehouseDashboardPage. On-time reads N/A when there is no sample / no active
storage rule.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouses/warehouse-fee.service.ts | 74 ++++++++++++
.../warehouse-inventory.controller.ts | 14 +++
.../warehouses/warehouse-inventory.service.ts | 101 ++++++++++++++++
.../warehouses/warehouse-rules.controller.ts | 7 ++
.../src/components/page/KpiStrip.tsx | 39 ++++--
.../components/warehouses/CycleTimeCard.tsx | 111 ++++++++++++++++++
.../components/warehouses/DwellAgingCard.tsx | 92 +++++++++++++++
.../warehouses/WarehouseOpsKpiStrip.tsx | 4 +
.../src/components/warehouses/index.ts | 2 +
.../src/components/warehouses/options.ts | 8 ++
.../backoffice/src/constants/URLS.ts | 3 +
.../backoffice/src/hooks/useWarehouses.ts | 24 ++++
.../warehouses/WarehouseDashboardPage.tsx | 10 ++
.../src/services/warehouse.service.ts | 9 ++
.../backoffice/src/types/warehouse.ts | 22 ++++
15 files changed, 510 insertions(+), 10 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
index 92aa36b85..3b61d7ff0 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
@@ -330,6 +330,80 @@ export class WarehouseFeeService {
return best;
}
+ /**
+ * On-time dispatch rate: the share of items dispatched in the last N days that
+ * LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
+ * <= freeDays, with freeDays resolved by the same rule matching the fee engine
+ * uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
+ * is nothing to measure (e.g. no dispatched items / no storage rules).
+ */
+ async onTimeDispatchStats(
+ windowDays = 90,
+ ): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
+ const storageRules = (
+ await this.feeRuleRepository.findAll({ where: { isActive: true } })
+ ).filter((r) => r.ruleType === 'STORAGE_FEE');
+
+ // Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
+ // the fields bestRule/matchScore reads, plus the two clock timestamps.
+ const rows: Array<
+ ItemAttributes & { arrivedAt: string; dispatchedAt: string }
+ > = await this.dataSource.query(
+ `SELECT inv.arrived_at AS "arrivedAt",
+ inv.dispatched_at AS "dispatchedAt",
+ inv.warehouse_id AS "warehouseId",
+ inv.yard_id AS "yardId",
+ inv.zone_id AS "zoneId",
+ w.facility_id AS "facilityId",
+ b.freight_type AS "freightType",
+ b.trade_direction AS "tradeDirection",
+ COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
+ COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
+ NULL AS "vehicleType"
+ FROM freight.warehouse_inventory inv
+ LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
+ LEFT JOIN freight.bookings b ON b.id = inv.booking_id
+ LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
+ LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
+ LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
+ LEFT JOIN freight.containers ct ON ct.id = inv.container_id
+ LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
+ LEFT JOIN LATERAL (
+ SELECT bc.container_type_id
+ FROM freight.booking_container bc
+ WHERE bc.booking_id = inv.booking_id
+ AND bc.deleted_at IS NULL
+ AND bc.container_type_id IS NOT NULL
+ ORDER BY bc.created_at ASC
+ LIMIT 1
+ ) booking_container_type ON true
+ LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
+ WHERE inv.deleted_at IS NULL
+ AND inv.arrived_at IS NOT NULL
+ AND inv.dispatched_at IS NOT NULL
+ AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
+ [windowDays],
+ );
+
+ let onTimeCount = 0;
+ for (const row of rows) {
+ const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
+ const elapsed = Math.max(
+ 0,
+ Math.ceil(
+ (new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
+ ),
+ );
+ if (elapsed <= freeDays) onTimeCount += 1;
+ }
+ const sampleSize = rows.length;
+ return {
+ sampleSize,
+ onTimeCount,
+ onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
+ };
+ }
+
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
return currency === 'ETB' ? 'ETB' : 'USD';
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index e9e8d95db..f64fddb3a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -81,6 +81,20 @@ export class WarehouseInventoryController {
return this.inventoryService.throughput(g);
}
+ @Get('dwell-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' })
+ dwellStats() {
+ return this.inventoryService.dwellStats();
+ }
+
+ @Get('cycle-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' })
+ cycleStats() {
+ return this.inventoryService.cycleStats();
+ }
+
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
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 793372d84..9c269270a 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
@@ -406,12 +406,14 @@ export class WarehouseInventoryService {
*/
async opsStats(): Promise<{
receivedToday: number;
+ receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> {
const [row]: Array<{
receivedToday: number;
+ receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
`SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
+ (SELECT count(*)::int FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
@@ -430,12 +434,109 @@ export class WarehouseInventoryService {
);
return {
receivedToday: row?.receivedToday ?? 0,
+ receivedYesterday: row?.receivedYesterday ?? 0,
pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 0,
};
}
+ /** In-warehouse statuses used by the dwell / aging metrics. */
+ private readonly IN_WAREHOUSE_STATUSES = [
+ 'RECEIVED',
+ 'UNLOADED',
+ 'STORED',
+ 'RESERVED',
+ 'READY_FOR_LOADING',
+ 'READY_FOR_PICKUP',
+ ];
+
+ /**
+ * Dwell time of items still in the warehouse: average days held plus a count
+ * per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
+ * back to created_at). Powers the dwell / aging histogram.
+ */
+ async dwellStats(): Promise<{
+ avgDwellDays: number;
+ inWarehouseCount: number;
+ buckets: Array<{ key: string; label: string; count: number }>;
+ }> {
+ const [row]: Array<{
+ avgDwellDays: number | null;
+ inWarehouseCount: number;
+ b0: number;
+ b1: number;
+ b2: number;
+ b3: number;
+ }> = await this.dataSource.query(
+ `WITH held AS (
+ SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL
+ AND status = ANY($1)
+ )
+ SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
+ count(*)::int AS "inWarehouseCount",
+ count(*) FILTER (WHERE age_days < 4)::int AS b0,
+ count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
+ count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
+ count(*) FILTER (WHERE age_days >= 15)::int AS b3
+ FROM held`,
+ [this.IN_WAREHOUSE_STATUSES],
+ );
+ return {
+ avgDwellDays: row?.avgDwellDays ?? 0,
+ inWarehouseCount: row?.inWarehouseCount ?? 0,
+ buckets: [
+ { key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
+ { key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
+ { key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
+ { key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
+ ],
+ };
+ }
+
+ /**
+ * Average stage cycle times over items dispatched in the last 90 days:
+ * arrived→ready, ready→loaded, loaded→dispatched, and the total
+ * arrived→dispatched (dock-to-dispatch). Days, to one decimal.
+ */
+ async cycleStats(): Promise<{
+ sampleSize: number;
+ avgDockToDispatchDays: number;
+ stages: Array<{ key: string; label: string; avgDays: number }>;
+ }> {
+ const gapDays = (from: string, to: string) =>
+ `round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
+ const [row]: Array<{
+ sampleSize: number;
+ total: number | null;
+ arrivedReady: number | null;
+ readyLoaded: number | null;
+ loadedDispatched: number | null;
+ }> = await this.dataSource.query(
+ `SELECT count(*)::int AS "sampleSize",
+ ${gapDays('arrived_at', 'dispatched_at')} AS "total",
+ ${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
+ ${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
+ ${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL
+ AND arrived_at IS NOT NULL
+ AND dispatched_at IS NOT NULL
+ AND dispatched_at > now() - interval '90 days'`,
+ );
+ return {
+ sampleSize: row?.sampleSize ?? 0,
+ avgDockToDispatchDays: row?.total ?? 0,
+ stages: [
+ { key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
+ { key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
+ { key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
+ ],
+ };
+ }
+
/**
* Received-vs-dispatched throughput as a server-side time series. Buckets by
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
index 7a5c53d38..3a60de4ea 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
@@ -96,6 +96,13 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency);
}
+ @Get('warehouse-fees/on-time-dispatch')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
+ @ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' })
+ onTimeDispatch() {
+ return this.feeService.onTimeDispatchStats();
+ }
+
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
index 6c8167a33..4dfd39b0c 100644
--- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
@@ -17,6 +17,11 @@ export interface KpiItem {
* into semantic tints.
*/
color?: string;
+ /**
+ * Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
+ * (green up, red down, muted zero). E.g. today's count minus yesterday's.
+ */
+ delta?: number;
}
export interface KpiStripProps {
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{loading ? (
) : (
-
- {item.value}
-
+
+
+ {item.value}
+
+ {item.delta != null && item.delta !== 0 ? (
+ 0 ? "edr-green" : "red"}
+ style={{ whiteSpace: "nowrap" }}
+ >
+ {item.delta > 0 ? "▲" : "▼"}
+ {Math.abs(item.delta)}
+
+ ) : null}
+
)}
{item.label}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx
new file mode 100644
index 000000000..3127f3bba
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CycleTimeCard.tsx
@@ -0,0 +1,111 @@
+import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
+import { Gauge } from 'lucide-react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { useOnTimeDispatch, useWarehouseCycleStats } from '@/hooks/useWarehouses';
+import { formatDays } from './options';
+
+function onTimeColor(pct: number | null | undefined): string {
+ if (pct == null) return 'edr-text';
+ if (pct >= 80) return 'teal';
+ if (pct >= 50) return 'orange';
+ return 'red';
+}
+
+/**
+ * Warehouse performance: on-time dispatch rate (items that left before their
+ * storage free-days expired), average dock-to-dispatch, and the per-stage
+ * cycle times that make it up.
+ */
+export function CycleTimeCard() {
+ const { data: cycle, isLoading: cycleLoading } = useWarehouseCycleStats();
+ const { data: onTime, isLoading: onTimeLoading } = useOnTimeDispatch();
+ const isLoading = cycleLoading || onTimeLoading;
+
+ const hasStages = (cycle?.sampleSize ?? 0) > 0;
+ const stages = cycle?.stages ?? [];
+
+ return (
+
+
+
+
+
+
+ Cycle time & on-time
+
+ Dispatch performance over the last 90 days
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+ On-time dispatch
+
+
+ {onTime?.onTimePct == null ? 'N/A' : `${onTime.onTimePct}%`}
+
+
+ {onTime?.onTimePct == null
+ ? 'No storage rule / sample'
+ : `${onTime.onTimeCount}/${onTime.sampleSize} left before free-days`}
+
+
+
+
+ Dock → dispatch
+
+
+ {hasStages ? formatDays(cycle?.avgDockToDispatchDays) : '—'}
+
+
+ avg over {cycle?.sampleSize ?? 0} dispatched
+
+
+
+
+ {hasStages ? (
+
+
+
+
+
+ [`${value} days`, 'Avg'] as [string, string]}
+ />
+
+
+
+ ) : (
+
+
+ Not enough dispatched items yet to chart stage times.
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx
new file mode 100644
index 000000000..a925f4a15
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DwellAgingCard.tsx
@@ -0,0 +1,92 @@
+import { Card, Group, Loader, Stack, Text, ThemeIcon } from '@mantine/core';
+import { Hourglass } from 'lucide-react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { useWarehouseDwellStats } from '@/hooks/useWarehouses';
+import { formatDays } from './options';
+
+/** Green → amber → red as items age. Aligned with the zone-occupancy heat scale. */
+const BUCKET_COLORS = ['#12b886', '#40c057', '#f08c00', '#fa5252'];
+
+/**
+ * Dwell time of items still in the warehouse: the average, plus how the current
+ * stock is spread across aging buckets (0–3 / 4–7 / 8–14 / 15+ days).
+ */
+export function DwellAgingCard() {
+ const { data, isLoading } = useWarehouseDwellStats();
+ const buckets = data?.buckets ?? [];
+ const hasItems = (data?.inWarehouseCount ?? 0) > 0;
+
+ return (
+
+
+
+
+
+
+ Dwell time & aging
+
+ How long current stock has been held
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+ Avg dwell
+
+
+ {hasItems ? formatDays(data?.avgDwellDays) : '—'}
+
+
+ {data?.inWarehouseCount ?? 0} item{(data?.inWarehouseCount ?? 0) === 1 ? '' : 's'} in warehouse
+
+
+
+
+ {hasItems ? (
+
+
+
+
+
+
+
+ {buckets.map((b, i) => (
+ |
+ ))}
+
+
+
+ ) : (
+
+
+ No items currently in the warehouse.
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx
index cf8e34343..88a60bbb4 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx
@@ -19,6 +19,10 @@ export function WarehouseOpsKpiStrip() {
value: data?.receivedToday ?? 0,
icon: PackageCheck,
color: "edr-green",
+ // Live signal: change vs yesterday's received count.
+ delta:
+ data != null ? data.receivedToday - data.receivedYesterday : undefined,
+ hint: "vs yesterday",
},
{
label: "Pending inspection",
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
index 7b3504480..78b9ef1f6 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
@@ -32,3 +32,5 @@ export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';
+export { DwellAgingCard } from './DwellAgingCard';
+export { CycleTimeCard } from './CycleTimeCard';
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
index 3a71ea2a5..d124a3325 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts
@@ -35,6 +35,14 @@ export const formatCapacity = (current: number, capacity: number | null | undefi
return `${cur} / ${formatNumber(capacity)}`;
};
+/** A day count as a short, human duration: "0.2d" / "3.5 days" / "—". */
+export const formatDays = (value: number | null | undefined) => {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) return '—';
+ const num = Number(value);
+ const rounded = Math.round(num * 10) / 10;
+ return `${rounded} ${rounded === 1 ? 'day' : 'days'}`;
+};
+
export const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index b9a78e843..5b718dad2 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -492,6 +492,8 @@ export const URL_CONSTANTS = {
OPS_STATS: "/warehouse-inventory/ops-stats",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
+ DWELL_STATS: "/warehouse-inventory/dwell-stats",
+ CYCLE_STATS: "/warehouse-inventory/cycle-stats",
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
@@ -564,6 +566,7 @@ export const URL_CONSTANTS = {
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
+ ON_TIME_DISPATCH: "/warehouse-fees/on-time-dispatch",
ACCRUAL_ACK: (inventoryId: string) =>
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
},
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index 8b10bfbd4..a4aac4411 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -160,6 +160,30 @@ export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
});
}
+/** Dwell time of in-warehouse items (average + aging buckets). */
+export function useWarehouseDwellStats() {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'dwell-stats'],
+ queryFn: () => warehouseService.dwellStats().then((r) => r.data),
+ });
+}
+
+/** Average stage cycle times over recently dispatched items. */
+export function useWarehouseCycleStats() {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'cycle-stats'],
+ queryFn: () => warehouseService.cycleStats().then((r) => r.data),
+ });
+}
+
+/** On-time dispatch rate (left before storage free-days expired). */
+export function useOnTimeDispatch() {
+ return useQuery({
+ queryKey: ['warehouse-fees', 'on-time-dispatch'],
+ queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
+ });
+}
+
/** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
index 8a5ffd08c..f7932f7d6 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
@@ -18,6 +18,8 @@ import {
import { PageContainer, PageHeader } from '@/components/page';
import {
AccrualDashboard,
+ CycleTimeCard,
+ DwellAgingCard,
WarehouseDashboardCharts,
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
@@ -125,6 +127,14 @@ export default function WarehouseDashboardPage() {
+
+ Performance
+
+
+
+
+
+
Zone capacity
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index 4a73ee1d3..fdf259169 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -7,6 +7,9 @@ import type {
ZoneOccupancy,
WarehouseOpsStats,
WarehouseThroughputPoint,
+ WarehouseDwellStats,
+ WarehouseCycleStats,
+ WarehouseOnTimeStats,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
@@ -399,6 +402,10 @@ export const warehouseService = {
apiClient.get(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),
),
+ dwellStats: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
+ cycleStats: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
autoUnloadArrived: () =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
@@ -455,6 +462,8 @@ export const warehouseService = {
apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }),
}),
+ onTimeDispatch: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ON_TIME_DISPATCH),
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
unacknowledgeAccrual: (inventoryId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index eb7d53739..f85e66b62 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1115,6 +1115,7 @@ export interface ZoneOccupancy {
/** At-a-glance warehouse ops counters for the KPI strip. */
export interface WarehouseOpsStats {
receivedToday: number;
+ receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
@@ -1127,6 +1128,27 @@ export interface WarehouseThroughputPoint {
dispatched: number;
}
+/** Dwell time of in-warehouse items: average days + aging-bucket counts. */
+export interface WarehouseDwellStats {
+ avgDwellDays: number;
+ inWarehouseCount: number;
+ buckets: Array<{ key: string; label: string; count: number }>;
+}
+
+/** Average stage cycle times over recently dispatched items. */
+export interface WarehouseCycleStats {
+ sampleSize: number;
+ avgDockToDispatchDays: number;
+ stages: Array<{ key: string; label: string; avgDays: number }>;
+}
+
+/** On-time dispatch rate (items that left before storage free-days expired). */
+export interface WarehouseOnTimeStats {
+ sampleSize: number;
+ onTimeCount: number;
+ onTimePct: number | null;
+}
+
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */
From ac90b04be000d2944cfcc0021ab79ee941d71f3e Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 14:19:40 +0000
Subject: [PATCH 04/11] =?UTF-8?q?feat(warehouse):=20P2=20dashboard=20?=
=?UTF-8?q?=E2=80=94=20gate/dock=20throughput=20+=20live=20auto-refresh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- gateStats(): items cleared through the gate today, average arrival→gate
turnaround (hours, 30d), and clearances per hour over the last 24h → new
GateThroughputCard in the dashboard Performance section.
- Live board: every dashboard query (dashboard, ops, throughput, dwell, cycle,
on-time, zone occupancy, accrual, gate) now auto-refreshes on a 60s interval,
with a "Live" indicator in the header.
New endpoint GET /warehouse-inventory/gate-stats (guarded). Deferred (need
upstream data): capacity forecast, labour productivity, WebSocket push, yard map.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouse-inventory.controller.ts | 7 ++
.../warehouses/warehouse-inventory.service.ts | 52 +++++++++++
.../warehouses/GateThroughputCard.tsx | 90 +++++++++++++++++++
.../src/components/warehouses/index.ts | 1 +
.../backoffice/src/constants/URLS.ts | 1 +
.../backoffice/src/hooks/useWarehouses.ts | 20 +++++
.../warehouses/WarehouseDashboardPage.tsx | 26 +++++-
.../src/services/warehouse.service.ts | 3 +
.../backoffice/src/types/warehouse.ts | 7 ++
9 files changed, 205 insertions(+), 2 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/GateThroughputCard.tsx
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index f64fddb3a..8bf4f571f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -95,6 +95,13 @@ export class WarehouseInventoryController {
return this.inventoryService.cycleStats();
}
+ @Get('gate-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' })
+ gateStats() {
+ return this.inventoryService.gateStats();
+ }
+
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
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 9c269270a..6dd941d26 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
@@ -537,6 +537,58 @@ export class WarehouseInventoryService {
};
}
+ /**
+ * Gate / dock throughput: items cleared through the gate today, the average
+ * arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
+ * bucketed per hour over the last 24 hours. Powers the gate throughput card.
+ */
+ async gateStats(): Promise<{
+ clearedToday: number;
+ avgTurnaroundHours: number | null;
+ byHour: Array<{ hour: string; count: number }>;
+ }> {
+ const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
+ await this.dataSource.query(
+ `SELECT
+ count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
+ round(
+ avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
+ FILTER (
+ WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
+ AND gate_cleared_at > now() - interval '30 days'
+ )::numeric,
+ 1
+ )::float8 AS "avgTurnaroundHours"
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL`,
+ );
+ const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
+ `WITH hours AS (
+ SELECT gs AS h
+ FROM generate_series(
+ date_trunc('hour', now()) - interval '23 hours',
+ date_trunc('hour', now()),
+ interval '1 hour'
+ ) gs
+ )
+ SELECT to_char(hours.h, 'HH24:00') AS hour,
+ COALESCE(g.cnt, 0)::int AS count
+ FROM hours
+ LEFT JOIN (
+ SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
+ GROUP BY 1
+ ) g ON g.ph = hours.h
+ ORDER BY hours.h`,
+ );
+ return {
+ clearedToday: scalar?.clearedToday ?? 0,
+ avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
+ byHour,
+ };
+ }
+
/**
* Received-vs-dispatched throughput as a server-side time series. Buckets by
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/GateThroughputCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/GateThroughputCard.tsx
new file mode 100644
index 000000000..dfd4574f5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/GateThroughputCard.tsx
@@ -0,0 +1,90 @@
+import { Card, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
+import { DoorOpen } from 'lucide-react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import { useWarehouseGateStats } from '@/hooks/useWarehouses';
+
+/**
+ * Gate / dock throughput: items cleared through the gate today, the average
+ * arrival→gate-clearance turnaround, and clearances per hour over the last 24h.
+ */
+export function GateThroughputCard() {
+ const { data, isLoading } = useWarehouseGateStats();
+ const byHour = data?.byHour ?? [];
+ const hasActivity = byHour.some((h) => h.count > 0);
+
+ return (
+
+
+
+
+
+
+ Gate & dock throughput
+
+ Gate clearances over the last 24 hours
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+ Cleared today
+
+
+ {data?.clearedToday ?? 0}
+
+
+ through the gate
+
+
+
+
+ Avg turnaround
+
+
+ {data?.avgTurnaroundHours == null ? '—' : `${data.avgTurnaroundHours} h`}
+
+
+ arrival → gate (30d)
+
+
+
+
+ {hasActivity ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+ No gate clearances in the last 24 hours.
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
index 78b9ef1f6..dae0e1d7f 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
@@ -34,3 +34,4 @@ export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';
export { DwellAgingCard } from './DwellAgingCard';
export { CycleTimeCard } from './CycleTimeCard';
+export { GateThroughputCard } from './GateThroughputCard';
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 5b718dad2..87c30044c 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -494,6 +494,7 @@ export const URL_CONSTANTS = {
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",
CYCLE_STATS: "/warehouse-inventory/cycle-stats",
+ GATE_STATS: "/warehouse-inventory/gate-stats",
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index a4aac4411..9327e5ee0 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -141,6 +141,7 @@ export function useZoneOccupancy(yardId?: string) {
return useQuery({
queryKey: ['warehouse-zones', 'occupancy', yardId ?? 'all'],
queryFn: () => warehouseService.zoneOccupancy(yardId).then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -149,14 +150,19 @@ export function useWarehouseOpsStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
+/** How often the live warehouse dashboard widgets auto-refresh (ms). */
+export const DASHBOARD_REFETCH_MS = 60_000;
+
/** Server-side received-vs-dispatched throughput time series. */
export function useWarehouseThroughput(granularity: 'week' | 'month' | 'year') {
return useQuery({
queryKey: ['warehouse-inventory', 'throughput', granularity],
queryFn: () => warehouseService.throughput(granularity).then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -165,6 +171,7 @@ export function useWarehouseDwellStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'dwell-stats'],
queryFn: () => warehouseService.dwellStats().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -173,6 +180,16 @@ export function useWarehouseCycleStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'cycle-stats'],
queryFn: () => warehouseService.cycleStats().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
+ });
+}
+
+/** Gate / dock throughput (cleared today, turnaround, hourly clearances). */
+export function useWarehouseGateStats() {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'gate-stats'],
+ queryFn: () => warehouseService.gateStats().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -181,6 +198,7 @@ export function useOnTimeDispatch() {
return useQuery({
queryKey: ['warehouse-fees', 'on-time-dispatch'],
queryFn: () => warehouseService.onTimeDispatch().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -189,6 +207,7 @@ export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
@@ -437,6 +456,7 @@ export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
+ refetchInterval: DASHBOARD_REFETCH_MS,
});
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
index f7932f7d6..d8298761a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
-import { Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
+import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
ClipboardList,
@@ -20,6 +20,7 @@ import {
AccrualDashboard,
CycleTimeCard,
DwellAgingCard,
+ GateThroughputCard,
WarehouseDashboardCharts,
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
@@ -71,6 +72,26 @@ export default function WarehouseDashboardPage() {
+ }
+ >
+ Live · updates every 60s
+
+ }
/>
{isLoading ? (
@@ -129,9 +150,10 @@ export default function WarehouseDashboardPage() {
Performance
-
+
+
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index fdf259169..2788d875a 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -10,6 +10,7 @@ import type {
WarehouseDwellStats,
WarehouseCycleStats,
WarehouseOnTimeStats,
+ WarehouseGateStats,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
@@ -406,6 +407,8 @@ export const warehouseService = {
apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.DWELL_STATS),
cycleStats: () =>
apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.CYCLE_STATS),
+ gateStats: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.GATE_STATS),
autoUnloadArrived: () =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index f85e66b62..197b1974f 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1149,6 +1149,13 @@ export interface WarehouseOnTimeStats {
onTimePct: number | null;
}
+/** Gate / dock throughput: cleared today, turnaround, and hourly clearances. */
+export interface WarehouseGateStats {
+ clearedToday: number;
+ avgTurnaroundHours: number | null;
+ byHour: Array<{ hour: string; count: number }>;
+}
+
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */
From 2044312d93087e9a8444d71dba913c42ccd63da8 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 14:51:21 +0000
Subject: [PATCH 05/11] feat(warehouse): stamp authenticated user as action
actor (performedBy)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Warehouse mutation endpoints now record the JWT-authenticated user as the actor
(user.id) instead of trusting a client-supplied performedBy string, unlocking
per-operator productivity metrics and a trustworthy audit trail. Covers receive,
receive-bulk, reserve, store, ready-for-loading, ready-for-pickup, load-onto-
train, bulk-dispatch, dispatch, deliver, gate-clearance, approve-delivery, the
Djibouti/import auto-unload actions, and fee-invoice generation. The prior
client value is kept only as a fallback for unauthenticated/internal calls.
move/load do not yet carry an actor (their DTOs have no performedBy) — separate
follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouse-inventory.controller.ts | 68 +++++++++++++------
.../warehouse-invoice.controller.ts | 5 +-
2 files changed, 50 insertions(+), 23 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 8bf4f571f..f8e9208af 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
+import { CurrentUser } from '@edr/api-common';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -127,7 +129,8 @@ export class WarehouseInventoryController {
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
- receiveBulk(@Body() dto: BulkReceiveDto) {
+ receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
+ dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.bulkReceive(dto);
}
@@ -173,15 +176,16 @@ export class WarehouseInventoryController {
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
+ @CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
+ return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
- bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
- return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
+ bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
+ return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
}
@Post('bulk-mark-inspected')
@@ -204,8 +208,12 @@ export class WarehouseInventoryController {
@Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
- gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
- return this.inventoryService.gateClearance(id, performedBy);
+ gateClearance(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body('performedBy') performedBy: string | undefined,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.gateClearance(id, user?.id ?? performedBy);
}
@Get('import/arrive-queue')
@@ -230,10 +238,10 @@ export class WarehouseInventoryController {
warehouseId?: string;
performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
- }) {
+ }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
- dto.performedBy,
+ user?.id ?? dto.performedBy,
dto.warehouseId,
dto.assignments,
);
@@ -275,8 +283,8 @@ export class WarehouseInventoryController {
@Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
- autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
- return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
+ autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
+ return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, user?.id ?? dto.performedBy);
}
@Get('import/pickup-ready-queue')
@@ -303,14 +311,16 @@ export class WarehouseInventoryController {
@Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
- receive(@Body() dto: ReceiveWarehouseInventoryDto) {
+ receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
+ dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.receive(dto);
}
@Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
- reserve(@Body() dto: ReserveInventoryDto) {
+ reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
+ dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.reserve(dto);
}
@@ -345,15 +355,19 @@ export class WarehouseInventoryController {
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
- store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
- return this.inventoryService.store(id, dto.performedBy, dto);
+ store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
+ return this.inventoryService.store(id, user?.id ?? dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
- readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
- return this.inventoryService.readyForLoading(id, performedBy);
+ readyForLoading(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body('performedBy') performedBy: string | undefined,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.readyForLoading(id, user?.id ?? performedBy);
}
@Post(':id/load')
@@ -366,8 +380,12 @@ export class WarehouseInventoryController {
@Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
- readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
- return this.inventoryService.readyForPickup(id, performedBy);
+ readyForPickup(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body('performedBy') performedBy: string | undefined,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.readyForPickup(id, user?.id ?? performedBy);
}
@Post(':id/release')
@@ -429,10 +447,11 @@ export class WarehouseInventoryController {
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
+ @CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.approveDeliveryForBooking(
bookingId,
- req.user?.id ?? req.user?.sub,
+ user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@@ -494,14 +513,19 @@ export class WarehouseInventoryController {
@Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
- deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
+ deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
+ dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
- dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
- return this.inventoryService.dispatch(id, performedBy);
+ dispatch(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body('performedBy') performedBy: string | undefined,
+ @CurrentUser() user: TCurrentUser,
+ ) {
+ return this.inventoryService.dispatch(id, user?.id ?? performedBy);
}
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
index 635ca1a10..6ea50db1b 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
+import { CurrentUser } from '@edr/api-common';
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -17,7 +19,8 @@ export class WarehouseInvoiceController {
@Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
- generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
+ generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
+ dto.performedBy = user?.id ?? dto.performedBy;
return this.invoiceService.generateForInventory(id, dto);
}
From 2647776d191d9c92ac3a4a22b21d5343102d94d8 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 15:24:05 +0000
Subject: [PATCH 06/11] feat(warehouse): stamp actor as display name instead of
UUID
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add actorLabel(user) — resolves the authenticated user to a readable name
(name → username → email → id) — and use it for the performed_by audit stamp on
every warehouse action, so the activity log shows a person, not a UUID. The
freight DB has no users table to join, so the name is stamped at write time.
approve-delivery keeps the raw user id (it is an id argument, not the audit
label). Existing rows keep their prior value; this applies going forward.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../modules/warehouses/current-actor.util.ts | 13 +++++++++
.../warehouse-inventory.controller.ts | 27 ++++++++++---------
.../warehouse-invoice.controller.ts | 3 ++-
3 files changed, 29 insertions(+), 14 deletions(-)
create mode 100644 apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts
diff --git a/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts b/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts
new file mode 100644
index 000000000..f4d04a6a5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts
@@ -0,0 +1,13 @@
+import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+
+/**
+ * Human-readable actor label for audit stamps (`performed_by` / `moved_by`).
+ * Prefers a display name, then username/email, so the activity log shows a
+ * person rather than a UUID. Returns undefined when there is no authenticated
+ * user (internal/cron calls), letting callers fall back to their prior value.
+ */
+export function actorLabel(user?: TCurrentUser | null): string | undefined {
+ if (!user) return undefined;
+ const name = user.name?.en?.trim() || user.name?.am?.trim();
+ return name || user.username || user.email || user.id || undefined;
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index f8e9208af..499e40f2e 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
@@ -130,7 +131,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
- dto.performedBy = user?.id ?? dto.performedBy;
+ dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.bulkReceive(dto);
}
@@ -178,14 +179,14 @@ export class WarehouseInventoryController {
@Body() dto: { inventoryIds: string[]; performedBy?: string },
@CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
+ return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
- return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
+ return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy);
}
@Post('bulk-mark-inspected')
@@ -213,7 +214,7 @@ export class WarehouseInventoryController {
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.gateClearance(id, user?.id ?? performedBy);
+ return this.inventoryService.gateClearance(id, actorLabel(user) ?? performedBy);
}
@Get('import/arrive-queue')
@@ -241,7 +242,7 @@ export class WarehouseInventoryController {
}, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
- user?.id ?? dto.performedBy,
+ actorLabel(user) ?? dto.performedBy,
dto.warehouseId,
dto.assignments,
);
@@ -284,7 +285,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
- return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, user?.id ?? dto.performedBy);
+ return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy);
}
@Get('import/pickup-ready-queue')
@@ -312,7 +313,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
- dto.performedBy = user?.id ?? dto.performedBy;
+ dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.receive(dto);
}
@@ -320,7 +321,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
- dto.performedBy = user?.id ?? dto.performedBy;
+ dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.reserve(dto);
}
@@ -356,7 +357,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
- return this.inventoryService.store(id, user?.id ?? dto.performedBy, dto);
+ return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@@ -367,7 +368,7 @@ export class WarehouseInventoryController {
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.readyForLoading(id, user?.id ?? performedBy);
+ return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy);
}
@Post(':id/load')
@@ -385,7 +386,7 @@ export class WarehouseInventoryController {
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.readyForPickup(id, user?.id ?? performedBy);
+ return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy);
}
@Post(':id/release')
@@ -514,7 +515,7 @@ export class WarehouseInventoryController {
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
- dto.performedBy = user?.id ?? dto.performedBy;
+ dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.deliver(id, dto);
}
@@ -526,6 +527,6 @@ export class WarehouseInventoryController {
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
- return this.inventoryService.dispatch(id, user?.id ?? performedBy);
+ return this.inventoryService.dispatch(id, actorLabel(user) ?? performedBy);
}
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
index 6ea50db1b..4ad469ce0 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
@@ -4,6 +4,7 @@ import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
+import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
@@ -20,7 +21,7 @@ export class WarehouseInvoiceController {
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
- dto.performedBy = user?.id ?? dto.performedBy;
+ dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.invoiceService.generateForInventory(id, dto);
}
From 2ffb2f9f6037e9a2ae9edd5b967eefbbefa972ed Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 20:05:55 +0300
Subject: [PATCH 07/11] remove auth
---
.../src/app/booking/auth-check/page.tsx | 5 +++-
.../portal/src/components/AppSidebar.tsx | 30 ++++++++++---------
.../portal/src/components/BottomTabBar.tsx | 20 +++++++------
3 files changed, 31 insertions(+), 24 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx
index d9c7f92a3..e38c07d90 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx
@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
-import { UserPlus, LogIn, ChevronLeft } from 'lucide-react';
+import { UserPlus, ChevronLeft } from 'lucide-react';
+// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
const [visible, setVisible] = useState(false);
@@ -95,6 +96,7 @@ export default function AuthCheckPage() {
+ {/* TODO: re-enable auth — SignIn or Register button commented out until auth integration
+ */}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
index 0acb5d9ea..08874193a 100644
--- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
@@ -192,20 +192,22 @@ export default function AppSidebar() {
)}
) : (
-
-
- Sign in
-
-
- Register
-
-
+ // TODO: re-enable auth — Sign in / Register links commented out until auth integration
+ //
+ //
+ // Sign in
+ //
+ //
+ // Register
+ //
+ //
+ null
)}
diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
index ee4cdccad..8017f3087 100644
--- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
+++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
@@ -1,9 +1,10 @@
'use client';
-import { Home, Phone, Ticket, User } from 'lucide-react';
+import { Home, Phone, Ticket } from 'lucide-react';
+// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { useAuthStore } from '@/lib/auth-store';
+// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth
// The linear, one-screen-at-a-time booking flow — each of these pages already
// has its own sticky mobile CTA bar (and the mobile step strip at the top),
@@ -21,7 +22,7 @@ const LINEAR_FLOW_PREFIXES = [
export default function BottomTabBar() {
const pathname = usePathname() ?? '';
- const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ // const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
if (isInLinearFlow) return null;
@@ -30,12 +31,13 @@ export default function BottomTabBar() {
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
- {
- href: isAuthenticated ? '/profile' : '/login',
- label: isAuthenticated ? 'Account' : 'Sign in',
- icon: User,
- match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
- },
+ // TODO: re-enable auth — auth login/register tab commented out until auth integration
+ // {
+ // href: isAuthenticated ? '/profile' : '/login',
+ // label: isAuthenticated ? 'Account' : 'Sign in',
+ // icon: User,
+ // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
+ // },
];
return (
From 9cf71e7e7a56ed515392eccfc3342a162ad9e597 Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 21:23:10 +0300
Subject: [PATCH 08/11] feat: ( payment ) add waafi webhook log
---
.../src/modules/webhooks/handlers/waafi-webhook.service.ts | 6 ++++++
.../src/modules/webhooks/webhooks.controller.ts | 6 +++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts
index 232957f79..3cd0b4c1c 100644
--- a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts
+++ b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts
@@ -45,6 +45,12 @@ export class WaafiWebhookService {
const mapped = this.provider.mapWebhookStatus(payment.status);
+ this.logger.log(
+ `Waafi authorization: ref=${payment.reference_id} txn=${payment.transaction_id} ` +
+ `rawStatus=${payment.status} mapped=${mapped} ` +
+ `signatureValid=${signatureValid} (fresh=${this.isFresh(timestamp)}) eventId=${eventId ?? "n/a"}`,
+ );
+
await this.processor.process({
provider: this.provider.method,
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
index f1e4fa6b0..6c923e8b4 100644
--- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
+++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts
@@ -127,10 +127,14 @@ export class WebhooksController {
@Req() req: { rawBody?: Buffer },
) {
- this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n");
this.logger.log(
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
);
+ this.logger.log(`Waafi webhook headers: ${JSON.stringify(headers)}`);
+ this.logger.log(`Waafi webhook payload: ${JSON.stringify(payload)}`);
+ this.logger.log(
+ `Waafi webhook raw body: ${req.rawBody?.toString("utf8") ?? "(none)"}`,
+ );
try {
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
const rawBody = req.rawBody?.toString("utf8") ?? "";
From f185da2163555f39c4e5deedf05022d51df8e535 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 21:28:35 +0300
Subject: [PATCH 09/11] Update seatmap for blocked seats
---
.../src/modules/bookings/bookings.service.ts | 2 +
.../portal/src/app/booking/detail/page.tsx | 2 +
.../portal/src/app/booking/review/page.tsx | 63 ++++++++++++++-----
.../portal/src/app/booking/seats/page.tsx | 2 +-
4 files changed, 53 insertions(+), 16 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 2a9de63d2..a6703f55e 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1879,6 +1879,8 @@ export class BookingsService {
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,
+ packageId: (booking as any).packageId ?? null,
+ isPackageBooking: !!(booking as any).packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (booking as any).returnBoardedAt ?? null,
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
index 025c212bf..c0c9f56bb 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
@@ -427,6 +427,7 @@ function BookingDetailContent() {
+ {!booking.isPackageBooking && booking.bookingType !== "PACKAGE" && (
Fare breakdown
@@ -481,6 +482,7 @@ function BookingDetailContent() {
);
})}
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
index 10051f0d3..95d390182 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx
@@ -52,6 +52,7 @@ export default function ReviewPage() {
const [timeLeft, setTimeLeft] = useState
('');
const [seatDetails, setSeatDetails] = useState>({});
const [fareBreakdown, setFareBreakdown] = useState(null);
+ const [returnFareBreakdown, setReturnFareBreakdown] = useState(null);
const [computedTotal, setComputedTotal] = useState(0);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
@@ -173,16 +174,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
// Prefers displayFareMinor (converted) from the fare breakdown API when available.
// Falls back to raw ETB seat fares (which are always in minor units).
const getPassengerSeatFare = (p: any, index?: number): number | null => {
+ if (isRoundTrip) {
+ // Seat-specific fares (set during seat selection) cover each leg separately — use them first.
+ if (p.outboundSeatFareMinor != null || p.inboundSeatFareMinor != null) {
+ if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
+ return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
+ }
+ // No seat-specific fares: fall back to the per-leg fare-breakdown totals for both legs.
+ if (!isPackageBooking && fareBreakdown?.passengers && returnFareBreakdown?.passengers && index != null) {
+ const obLine = fareBreakdown.passengers[index];
+ const retLine = returnFareBreakdown.passengers[index];
+ const obFare = obLine?.displayFareMinor ?? obLine?.fareMinor;
+ const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
+ if (obFare != null && retFare != null) return obFare + retFare;
+ }
+ return null;
+ }
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
const line = fareBreakdown.passengers[index];
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
if (displayFare != null) return displayFare;
}
- if (isRoundTrip) {
- if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null;
- if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2;
- return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0);
- }
if (p.seatFareMinor == null) return null;
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
};
@@ -541,6 +553,22 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
setFareBreakdown(result);
+
+ // For round-trips, also fetch the return leg's fare breakdown so the review page
+ // can display and send the correct combined total (outbound + return per passenger).
+ if (isRoundTrip && inboundSchedule) {
+ const returnScheduleId = (inboundSchedule as any).id;
+ const returnParams = new URLSearchParams({
+ scheduleId: returnScheduleId,
+ originStationId: searchCriteria.destinationStationId,
+ destinationStationId: searchCriteria.originStationId,
+ passengers: passengersParam,
+ displayCurrency: displayCurrencyCode,
+ ...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
+ });
+ const returnResult: any = await apiClient.get(`/search/fare-breakdown?${returnParams}`);
+ setReturnFareBreakdown(returnResult);
+ }
} catch (err) {
}
})();
@@ -566,7 +594,11 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
if (isFreeChild) return sum;
const seatFare = getPassengerSeatFare(p, i);
- const displayFare = line?.displayFareMinor ?? line?.fareMinor;
+ // For round-trips the fallback must combine both legs; for one-way it's the single-leg fare.
+ const obFare = line?.displayFareMinor ?? line?.fareMinor;
+ const retLine = returnFareBreakdown?.passengers?.[i];
+ const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
+ const displayFare = isRoundTrip && retFare != null ? (obFare ?? 0) + retFare : obFare;
return sum + (seatFare ?? displayFare ?? 0);
}, 0);
@@ -586,26 +618,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
? isPkgFreeChild(i)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
- // Per-leg fares for round trips — use converted amounts from fareBreakdown when available
- const displayFare = line?.displayFareMinor ?? line?.fareMinor;
+ // Per-leg fares for round trips — prefer seat-specific fares, then per-leg breakdowns.
+ const retLine = returnFareBreakdown?.passengers?.[i];
+ const obBreakdownFare = line?.displayFareMinor ?? line?.fareMinor;
+ const retBreakdownFare = retLine?.displayFareMinor ?? retLine?.fareMinor;
const outboundFare: number | null = isRoundTrip
? (isPackageBooking
? (packageTierPriceMinor ?? null)
- : (displayFare != null
- ? Math.round(displayFare / 2)
- : ((p as any).outboundSeatFareMinor ?? null)))
+ : ((p as any).outboundSeatFareMinor ?? obBreakdownFare ?? null))
: null;
const inboundFare: number | null = isRoundTrip
? (isPackageBooking
? (packageTierPriceMinor ?? null)
- : (displayFare != null
- ? Math.round(displayFare / 2)
- : ((p as any).inboundSeatFareMinor ?? null)))
+ : ((p as any).inboundSeatFareMinor ?? retBreakdownFare ?? null))
: null;
const seatFare = getPassengerSeatFare(p, i);
+ const combinedDisplayFare = isRoundTrip && retBreakdownFare != null
+ ? (obBreakdownFare ?? 0) + retBreakdownFare
+ : obBreakdownFare;
const passengerTotal = isPackageBooking
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
- : (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0));
+ : (isFreeChild ? 0 : (seatFare ?? combinedDisplayFare ?? 0));
return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index a143f7a1e..b38e3aee9 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -52,7 +52,7 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) =>
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
: bed.status === "AVAILABLE"
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
- : bed.status === "BOOKED"
+ : bed.status === "BOOKED" || bed.status === "BLOCKED"
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
}`}
From 63accc8bd321673b415cec287601de9e4a82b775 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 22:03:29 +0300
Subject: [PATCH 10/11] Fix booking detail for roundtrip
---
.../portal/src/app/booking/detail/page.tsx | 359 +++++++-----------
1 file changed, 143 insertions(+), 216 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
index c0c9f56bb..a54ce795b 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx
@@ -412,6 +412,103 @@ function BookingDetailContent() {
return displayTotal / etbTotal;
})();
+ // Flight-style origin → train → destination timeline for a single leg's schedule.
+ // Shared by both the pending-payment "Trip Summary" card and the confirmed booking's
+ // "Journey Details" card so a round trip's outbound and return legs render identically —
+ // each of those cards calls this once per leg instead of hardcoding booking.schedule only.
+ const renderJourneyTimeline = (schedule: any) => (
+
+
+
+
+
+ {schedule?.departureAt ? formatTime(schedule.departureAt) : "--:--"}
+
+
+ {schedule?.departureAt
+ ? `${format(toZonedDate(new Date(schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(schedule.departureAt)}`
+ : "N/A"}
+
+
+ {schedule?.origin?.name}
+
+
+ {schedule?.origin?.city}
+
+
+
+
+
+
+
+
+
+
Train {schedule?.trainNumber}
+
+
+
+
+
+
+ {schedule?.arrivalAt ? formatTime(schedule.arrivalAt) : "--:--"}
+
+
+ {schedule?.arrivalAt
+ ? `${format(toZonedDate(new Date(schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(schedule.arrivalAt)}`
+ : "N/A"}
+
+
+ {schedule?.destination?.name}
+
+
+ {schedule?.destination?.city}
+
+
+
+
+ );
+
+ // Renders one or both legs' timelines with an "Outbound Journey"/"Return Journey"
+ // heading pair when the booking has a return leg, or a single "Your Journey" heading
+ // for one-way bookings — used by both the pending-payment and confirmed views.
+ const renderJourneyLegs = () => (
+ <>
+
+
+
+ {isRoundTripBooking ? "Outbound Journey" : "Your Journey"}
+
+ {booking.passengers?.[0]?.seat?.seatClass && (
+
+ {booking.passengers[0].seat.seatClass}
+
+ )}
+
+ {renderJourneyTimeline(booking.schedule)}
+
+ {isRoundTripBooking && booking.returnSchedule && (
+
+
+ {renderJourneyTimeline(booking.returnSchedule)}
+
+ )}
+ >
+ );
+
// Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per
// passenger, Total with a loading spinner while a currency conversion is in flight, and
// a note confirming what will actually be charged once a payment method is selected.
@@ -586,137 +683,62 @@ function BookingDetailContent() {
Trip Summary
-
-
-
- Your Journey
-
- {booking.passengers?.[0]?.seat?.seatClass && (
-
- {booking.passengers[0].seat.seatClass}
-
- )}
-
-
- {/* Flight-style timeline */}
-
- {/* Left column: Timeline with dots and line */}
-
- {/* Origin dot */}
-
- {/* Vertical line */}
-
- {/* Destination dot */}
-
-
-
- {/* Right column: Content */}
-
- {/* Origin */}
-
-
- {booking.schedule?.departureAt
- ? formatTime(booking.schedule.departureAt)
- : "--:--"}
-
-
- {booking.schedule?.departureAt
- ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}`
- : "N/A"}
-
-
- {booking.schedule?.origin?.name}
-
-
- {booking.schedule?.origin?.city}
-
-
-
- {/* Journey Info */}
-
-
-
-
-
-
-
- Train {booking.schedule?.trainNumber}
-
-
- {booking.schedule?.trainName && (
-
- {booking.schedule.trainName}
-
- )}
-
-
-
- {/* Destination */}
-
-
- {booking.schedule?.arrivalAt
- ? formatTime(booking.schedule.arrivalAt)
- : "--:--"}
-
-
- {booking.schedule?.arrivalAt
- ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}`
- : "N/A"}
-
-
- {booking.schedule?.destination?.name}
-
-
- {booking.schedule?.destination?.city}
-
-
-
-
+ {renderJourneyLegs()}
- {booking.passengers?.length || 0} Passenger(s)
+ {groupedPassengers.length} Passenger(s)
- {booking.passengers?.map(
- (passenger: any, idx: number) => (
-
-
-
- {passenger.fullName}
-
-
- {passenger.category} • Coach{" "}
- {passenger.seat?.coach}
-
+ {groupedPassengers.map((passenger: any, idx: number) => (
+
+
+
+ {passenger.fullName}
-
-
- Seat {passenger.seat?.number}
-
-
- {passenger.seat?.seatClass}
-
+
+ {passenger.category}
- ),
- )}
+ {isRoundTripBooking ? (
+
+ {(
+ [
+ { legLabel: "Outbound", seat: passenger.outboundSeat },
+ { legLabel: "Return", seat: passenger.returnSeat },
+ ] as const
+ ).map(({ legLabel, seat }) => (
+
+
+ {legLabel}
+
+
+ Seat {seat?.number ?? "--"}
+
+
+ {seat?.seatClass}
+
+
+ ))}
+
+ ) : (
+
+
+ Seat {passenger.outboundSeat?.number}
+
+
+ {passenger.outboundSeat?.seatClass}
+
+
+ )}
+
+ ))}
@@ -994,102 +1016,7 @@ function BookingDetailContent() {
Journey Details
-
-
-
- Your Journey
-
- {booking.passengers?.[0]?.seat?.seatClass && (
-
- {booking.passengers[0].seat.seatClass}
-
- )}
-
-
- {/* Flight-style timeline */}
-
- {/* Left column: Timeline with dots and line */}
-
- {/* Origin dot */}
-
- {/* Vertical line */}
-
- {/* Destination dot */}
-
-
-
- {/* Right column: Content */}
-
- {/* Origin */}
-
-
- {booking.schedule?.departureAt
- ? formatTime(booking.schedule.departureAt)
- : "--:--"}
-
-
- {booking.schedule?.departureAt
- ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}`
- : "N/A"}
-
-
- {booking.schedule?.origin?.name}
-
-
- {booking.schedule?.origin?.city}
-
-
-
- {/* Journey Info */}
-
-
-
-
-
-
-
- Train {booking.schedule?.trainNumber}
-
-
- {booking.schedule?.trainName && (
-
- {booking.schedule.trainName}
-
- )}
-
-
-
- {/* Destination */}
-
-
- {booking.schedule?.arrivalAt
- ? formatTime(booking.schedule.arrivalAt)
- : "--:--"}
-
-
- {booking.schedule?.arrivalAt
- ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}`
- : "N/A"}
-
-
- {booking.schedule?.destination?.name}
-
-
- {booking.schedule?.destination?.city}
-
-
-
-
+ {renderJourneyLegs()}
From 7725b0d5d4e383733d03a31d10c216b842ec200f Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 23:14:05 +0300
Subject: [PATCH 11/11] Update payments.service.ts
---
apps/edr-passenger-api/src/modules/payments/payments.service.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
index 36d3fbd1d..5bd068255 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -261,7 +261,7 @@ export class PaymentsService {
// Booking is in ETB — convert to the provider's settlement currency.
chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
- 'ETB',
+ booking.currency,
chargeCurrency,
);
}