From a542a1389396eff4a092f281e845fe4fd9275d7e Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Mon, 13 Jul 2026 13:38:28 +0000
Subject: [PATCH 01/67] wAREHOUSE kPI strips
---
.../warehouse-inventory.controller.ts | 6 +++
.../warehouses/warehouse-inventory.service.ts | 39 ++++++++++++++++
.../warehouses/WarehouseOpsKpiStrip.tsx | 45 +++++++++++++++++++
.../src/components/warehouses/index.ts | 1 +
.../backoffice/src/constants/URLS.ts | 1 +
.../backoffice/src/hooks/useWarehouses.ts | 8 ++++
.../src/pages/warehouses/ArrivalQueuePage.tsx | 3 ++
.../ExportDjiboutiUnloadingQueuePage.tsx | 3 ++
.../src/pages/warehouses/LoadingQueuePage.tsx | 3 ++
.../src/services/warehouse.service.ts | 3 ++
.../backoffice/src/types/warehouse.ts | 8 ++++
11 files changed, 120 insertions(+)
create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.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 21f2663b6..dcd32a10a 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
@@ -52,6 +52,12 @@ export class WarehouseInventoryController {
return this.inventoryService.arrivalQueue();
}
+ @Get('ops-stats')
+ @ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
+ opsStats() {
+ return this.inventoryService.opsStats();
+ }
+
@Get('zone-occupancy')
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
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 7c8edcce0..a96b3f804 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
@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/
+ /**
+ * At-a-glance warehouse ops counters for the KPI strip:
+ * - receivedToday: items received today
+ * - pendingInspection: RECEIVED items not yet inspected
+ * - trucksOnSite: customer trucks arrived but not departed
+ * - itemsAging: in-warehouse items older than 7 days (demurrage risk)
+ */
+ async opsStats(): Promise<{
+ receivedToday: number;
+ pendingInspection: number;
+ trucksOnSite: number;
+ itemsAging: number;
+ }> {
+ const [row]: Array<{
+ receivedToday: number;
+ pendingInspection: number;
+ trucksOnSite: number;
+ itemsAging: number;
+ }> = await this.dataSource.query(
+ `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 status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
+ (SELECT count(*)::int FROM freight.customer_truck_assignments
+ WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
+ (SELECT count(*)::int FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL
+ AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
+ AND created_at < now() - interval '7 days') AS "itemsAging"`,
+ );
+ return {
+ receivedToday: row?.receivedToday ?? 0,
+ pendingInspection: row?.pendingInspection ?? 0,
+ trucksOnSite: row?.trucksOnSite ?? 0,
+ itemsAging: row?.itemsAging ?? 0,
+ };
+ }
+
/**
* 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/WarehouseOpsKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx
new file mode 100644
index 000000000..cf8e34343
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseOpsKpiStrip.tsx
@@ -0,0 +1,45 @@
+import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
+
+import { KpiStrip } from "@/components/page";
+import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
+
+/**
+ * At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
+ * on-site, items aging). Drop-in for any warehouse ops page header.
+ */
+export function WarehouseOpsKpiStrip() {
+ const { data, isLoading } = useWarehouseOpsStats();
+
+ return (
+ 7d)",
+ value: data?.itemsAging ?? 0,
+ icon: AlertTriangle,
+ color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
+ hint: "In warehouse over 7 days",
+ },
+ ]}
+ />
+ );
+}
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 075237198..da20d6fa7 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
@@ -30,3 +30,4 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
+export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index 2eaee8090..f76dbbde2 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
+ OPS_STATS: "/warehouse-inventory/ops-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 59709d2d7..da1bfb867 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
@@ -144,6 +144,14 @@ export function useZoneOccupancy(yardId?: string) {
});
}
+/** At-a-glance warehouse ops counters for the KPI strip. */
+export function useWarehouseOpsStats() {
+ return useQuery({
+ queryKey: ['warehouse-inventory', 'ops-stats'],
+ queryFn: () => warehouseService.opsStats().then((r) => r.data),
+ });
+}
+
export function useCreateZone() {
const qc = useQueryClient();
return useMutation({
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx
index 821589271..61afa426c 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx
@@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
VisualEmptyState,
+ WarehouseOpsKpiStrip,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -291,6 +292,8 @@ export default function ArrivalQueuePage() {
breadcrumbs={[{ label: 'Arrival queue' }]}
/>
+
+
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx
index 6e694ed1e..735026fa7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx
@@ -29,6 +29,7 @@ import {
ActivityTimeline,
InventoryMovementHistoryTable,
VisualEmptyState,
+ WarehouseOpsKpiStrip,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
/>
+
+
{trains.length} arrived export train(s)
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
index e9bb3a162..a03c7038e 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx
@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryWorkbench,
VisualEmptyState,
+ WarehouseOpsKpiStrip,
formatNumber,
} from '@/components/warehouses';
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
}
/>
+
+
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 ea0baa32e..08aa01476 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
+ WarehouseOpsStats,
AllocationCriteria,
AllocationPreviewResult,
AllocationRule,
@@ -370,6 +371,8 @@ export const warehouseService = {
apiClient.get(
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
),
+ opsStats: () =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_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 4023bf986..eb04c39bc 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1107,3 +1107,11 @@ export interface ZoneOccupancy {
/** 0–100+, container-count based (weight is a rough fallback). Null if no capacity set. */
occupancyPct: number | null;
}
+
+/** At-a-glance warehouse ops counters for the KPI strip. */
+export interface WarehouseOpsStats {
+ receivedToday: number;
+ pendingInspection: number;
+ trucksOnSite: number;
+ itemsAging: number;
+}
From d9fe79e16021772bd20072c7afd17896e76ace2d Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 08:07:46 +0000
Subject: [PATCH 02/67] accrual dashboard for port and terminal or warehouse
related documents
---
.../warehouses/warehouse-fee.service.ts | 184 +++++++++++++++++-
.../warehouse-inventory.controller.ts | 20 ++
.../warehouses/warehouse-inventory.service.ts | 25 +++
.../warehouses/warehouse-rules.controller.ts | 6 +
.../warehouses/AccrualDashboard.tsx | 167 ++++++++++++++++
.../warehouses/InventoryWorkbench.tsx | 39 +++-
.../warehouses/WarehouseInventoryTable.tsx | 11 +-
.../src/components/warehouses/index.ts | 1 +
.../src/components/warehouses/pdf.ts | 13 ++
.../backoffice/src/constants/URLS.ts | 1 +
.../backoffice/src/hooks/useWarehouses.ts | 8 +
.../warehouses/WarehouseInvoicesPage.tsx | 8 +
.../src/services/warehouse.service.ts | 5 +
.../backoffice/src/types/warehouse.ts | 27 +++
.../portal/src/services/bookings.service.ts | 14 ++
15 files changed, 526 insertions(+), 3 deletions(-)
create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.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 14a3375c7..720f30766 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
@@ -1,7 +1,10 @@
-import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { Cron, CronExpression } from '@nestjs/schedule';
import { ExchangeService } from '@edr/api-common';
+import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm';
+import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -26,6 +29,32 @@ interface ItemAttributes {
zoneId: string | null;
}
+export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
+
+export interface AccrualDashboardRow {
+ inventoryId: string;
+ status: string;
+ bookingId: string | null;
+ companyId: string | null;
+ bookingReference: string | null;
+ customerName: string | null;
+ warehouseCode: string | null;
+ zoneCode: string | null;
+ receivedAt: string | null;
+ currency: string;
+ accruedAmount: number;
+ freeDaysLeft: number | null;
+ charging: boolean;
+ alert: AccrualAlert;
+ breakdown: Array<{
+ type: FeeRuleType;
+ amount: number;
+ freeDays: number;
+ elapsedDays: number;
+ chargeableDays: number;
+ }>;
+}
+
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
@@ -70,12 +99,80 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
@Injectable()
export class WarehouseFeeService {
+ private readonly logger = new Logger(WarehouseFeeService.name);
+
constructor(
private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
private readonly exchangeService: ExchangeService,
+ private readonly inbox: NotificationInboxService,
) {}
+ /**
+ * Daily accrual alerts: for every in-warehouse item that is charging or within
+ * its last free days, send the customer an in-app notification with the
+ * outstanding accrued amount so they can collect before (more) charges hit.
+ */
+ @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
+ async sendAccrualAlerts(): Promise {
+ try {
+ const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK');
+ if (!alerts.length) return;
+ this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
+
+ // Per-customer: notify each company about its own items.
+ for (const row of alerts.filter((r) => r.companyId)) {
+ const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
+ const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
+ const body = row.charging
+ ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
+ : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
+ try {
+ await this.inbox.notify({
+ recipients: { companyId: row.companyId! },
+ audience: NotificationAudience.PORTAL,
+ type: NotificationType.BOOKING_STATUS,
+ title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
+ body,
+ link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
+ data: {
+ inventoryId: row.inventoryId,
+ bookingId: row.bookingId,
+ alert: row.alert,
+ accruedAmount: row.accruedAmount,
+ action: 'ACCRUAL_ALERT',
+ },
+ });
+ } catch (err) {
+ this.logger.warn(
+ `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
+ );
+ }
+ }
+
+ // Ops staff: one digest covering every alerting item.
+ const charging = alerts.filter((r) => r.charging).length;
+ const nearing = alerts.length - charging;
+ const currency = alerts[0]?.currency ?? 'USD';
+ const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
+ try {
+ await this.inbox.notify({
+ recipients: { allBackoffice: true },
+ audience: NotificationAudience.BACKOFFICE,
+ type: NotificationType.BOOKING_STATUS,
+ title: 'Warehouse fee accruals need attention',
+ body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
+ link: '/dashboard/warehouse-fee-invoices',
+ data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
+ });
+ } catch (err) {
+ this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
+ }
+ } catch (err) {
+ this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
+ }
+ }
+
// ── Rule CRUD ──────────────────────────────────────────────────────────────
listRules(): Promise {
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
@@ -416,6 +513,91 @@ export class WarehouseFeeService {
};
}
+ /**
+ * Live accrual dashboard: for every item still in the warehouse, the fees
+ * accruing right now (storage + demurrage + double-handling), how many free
+ * days remain, and an alert level so staff can act before charges land.
+ */
+ async accrualDashboard(billingCurrency = 'USD'): Promise {
+ const items: Array<{
+ id: string;
+ status: string;
+ bookingId: string | null;
+ companyId: string | null;
+ bookingReference: string | null;
+ customerName: string | null;
+ warehouseCode: string | null;
+ zoneCode: string | null;
+ receivedAt: string | null;
+ }> = await this.dataSource.query(
+ `SELECT inv.id,
+ inv.status,
+ b.id AS "bookingId",
+ b.company_id AS "companyId",
+ b.reference AS "bookingReference",
+ c.name AS "customerName",
+ w.code AS "warehouseCode",
+ z.code AS "zoneCode",
+ inv.created_at AS "receivedAt"
+ FROM freight.warehouse_inventory inv
+ LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
+ LEFT JOIN freight.companies c ON c.id = b.company_id
+ LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
+ LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
+ WHERE inv.deleted_at IS NULL
+ AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
+ ORDER BY inv.created_at ASC`,
+ );
+
+ const rows = await Promise.all(
+ items.map(async (it): Promise => {
+ const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
+ (p) => p.ruleId,
+ );
+ const accruedAmount =
+ Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
+ const charging = previews.some((p) => p.chargeableDays > 0);
+ const freeDaysLeftVals = previews
+ .filter((p) => p.endIsOpen)
+ .map((p) => Math.max(0, p.freeDays - p.elapsedDays));
+ const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
+ const alert: AccrualAlert = charging
+ ? 'CHARGING'
+ : freeDaysLeft != null && freeDaysLeft <= 2
+ ? 'WARNING'
+ : 'OK';
+ return {
+ inventoryId: it.id,
+ status: it.status,
+ bookingId: it.bookingId,
+ companyId: it.companyId,
+ bookingReference: it.bookingReference,
+ customerName: it.customerName,
+ warehouseCode: it.warehouseCode,
+ zoneCode: it.zoneCode,
+ receivedAt: it.receivedAt,
+ currency: billingCurrency,
+ accruedAmount,
+ freeDaysLeft,
+ charging,
+ alert,
+ breakdown: previews.map((p) => ({
+ type: p.ruleType,
+ amount: p.amount,
+ freeDays: p.freeDays,
+ elapsedDays: p.elapsedDays,
+ chargeableDays: p.chargeableDays,
+ })),
+ };
+ }),
+ );
+
+ const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
+ return rows.sort(
+ (a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount,
+ );
+ }
+
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise {
const item = await this.loadItem(inventoryId);
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 dcd32a10a..f45abe507 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
@@ -368,6 +368,26 @@ export class WarehouseInventoryController {
return this.handoverService.requestSignature(bookingId);
}
+ @Get('bookings/:bookingId/grn-document')
+ @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
+ async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
+ const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
+ res.setHeader('Content-Length', buffer.length);
+ return res.send(buffer);
+ }
+
+ @Get('bookings/:bookingId/release-document')
+ @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
+ async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
+ const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
+ res.setHeader('Content-Length', buffer.length);
+ return res.send(buffer);
+ }
+
@Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
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 a96b3f804..8bce58f01 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
@@ -3265,6 +3265,31 @@ export class WarehouseInventoryService {
return this.handoverDocument(inv.id);
}
+ /** Resolve the primary warehouse-inventory item for a booking (most recent). */
+ private async primaryInventoryIdForBooking(bookingId: string): Promise {
+ const [inv]: Array<{ id: string }> = await this.dataSource.query(
+ `SELECT id FROM freight.warehouse_inventory
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY updated_at DESC NULLS LAST, created_at DESC
+ LIMIT 1`,
+ [bookingId],
+ );
+ if (!inv) {
+ throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
+ }
+ return inv.id;
+ }
+
+ /** Booking-scoped GRN document (customer portal). */
+ async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
+ return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
+ }
+
+ /** Booking-scoped gate-clearance / release document (customer portal). */
+ async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
+ return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
+ }
+
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
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 d35587d9e..1fecf9392 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
@@ -77,6 +77,12 @@ export class WarehouseRulesController {
return this.feeService.deleteRule(id);
}
+ @Get('warehouse-fees/accrual-dashboard')
+ @ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
+ accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
+ return this.feeService.accrualDashboard(billingCurrency);
+ }
+
@Get('warehouse-inventory/:id/fee-preview')
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx
new file mode 100644
index 000000000..2ac3fa2f9
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx
@@ -0,0 +1,167 @@
+import { useMemo } from 'react';
+import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
+import { AlertTriangle, Clock, DollarSign } from 'lucide-react';
+
+import { useAccrualDashboard } from '@/hooks/useWarehouses';
+import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
+
+const ALERT_META: Record = {
+ CHARGING: { color: 'red', label: 'Charging' },
+ WARNING: { color: 'orange', label: 'Free days ending' },
+ OK: { color: 'teal', label: 'Within free days' },
+};
+
+function money(amount: number, currency: string): string {
+ return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
+}
+
+function freeDaysLabel(row: AccrualDashboardRow): string {
+ if (row.charging) return 'charging now';
+ if (row.freeDaysLeft == null) return '—';
+ return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
+}
+
+/**
+ * Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
+ * sorted so items already charging (or about to) surface first. Read-only.
+ */
+export function AccrualDashboard() {
+ const { data: rows = [], isLoading } = useAccrualDashboard();
+
+ const summary = useMemo(() => {
+ const currency = rows[0]?.currency ?? 'USD';
+ return {
+ currency,
+ charging: rows.filter((r) => r.alert === 'CHARGING').length,
+ atRisk: rows.filter((r) => r.alert === 'WARNING').length,
+ totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
+ };
+ }, [rows]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ }
+ label="Accruing now"
+ value={money(summary.totalAccruing, summary.currency)}
+ color="edr-green"
+ />
+ }
+ label="Charging"
+ value={summary.charging}
+ color={summary.charging > 0 ? 'red' : 'gray'}
+ />
+ }
+ label="Free days ending (≤2d)"
+ value={summary.atRisk}
+ color={summary.atRisk > 0 ? 'orange' : 'gray'}
+ />
+
+
+
+ {rows.length === 0 ? (
+
+ No in-warehouse items are accruing fees.
+
+ ) : (
+
+
+
+
+ Booking
+ Customer
+ Location
+ Status
+ Accrued
+ Free days
+ Alert
+
+
+
+ {rows.map((row) => {
+ const meta = ALERT_META[row.alert];
+ return (
+
+
+
+ {row.bookingReference ?? row.inventoryId.slice(0, 8)}
+
+
+ {row.customerName ?? '—'}
+
+
+ {[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
+
+
+
+
+ {row.status}
+
+
+
+ 0 ? 'red' : undefined}>
+ {money(row.accruedAmount, row.currency)}
+
+
+
+
+ {freeDaysLabel(row)}
+
+
+
+
+ {meta.label}
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+}
+
+function StatCard({
+ icon,
+ label,
+ value,
+ color,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: React.ReactNode;
+ color: string;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
index c4f1f5cec..d8b370de0 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
-import { openPdfBlob } from './pdf';
+import { openPdfBlob, saveBlob } from './pdf';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
}
};
+ // One-click bundle: download every available document for the item (GRN +
+ // gate clearance / release order + handover). Best-effort — docs that aren't
+ // generatable yet for this item are skipped.
+ const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
+ setBusyId(item.id);
+ const ref = item.booking?.reference ?? item.bookingId ?? item.id;
+ const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
+ { name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
+ { name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
+ { name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
+ ];
+ let saved = 0;
+ for (const job of jobs) {
+ try {
+ const response = await job.fn();
+ saveBlob(response.data, job.name);
+ saved += 1;
+ } catch {
+ // Document not available for this item yet — skip it.
+ }
+ }
+ setBusyId(null);
+ if (saved === 0) {
+ toast({
+ variant: 'destructive',
+ title: 'No documents available',
+ description: 'This item has no GRN, gate clearance or handover document yet.',
+ });
+ } else {
+ toast({
+ title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
+ description: `Bundle for ${ref} (available documents only).`,
+ });
+ }
+ };
+
const acceptLastMile = async (item: WarehouseInventoryItem) => {
const reference = item.booking?.reference;
if (!reference) {
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onHandoverDocument={openHandoverDocument}
+ onDownloadBundle={downloadDocumentBundle}
onLastMile={onLastMile ? acceptLastMile : undefined}
selectedIds={selected}
onToggleSelect={toggleSelect}
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
index 51650b459..de262767f 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
-import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
+import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
+ onDownloadBundle?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void;
selectedIds?: Set;
onToggleSelect?: (id: string) => void;
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
onFeePreview,
onReleaseDocument,
onHandoverDocument,
+ onDownloadBundle,
onLastMile,
selectedIds,
onToggleSelect,
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
)}
+ {onDownloadBundle && item.grnNumber && (
+
+ onDownloadBundle(item)}>
+
+
+
+ )}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
onLastMile(item)}>
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 da20d6fa7..7b3504480 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts
@@ -31,3 +31,4 @@ export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
+export { AccrualDashboard } from './AccrualDashboard';
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts
index 7a467b9db..91ca2ec16 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/pdf.ts
@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
URL.revokeObjectURL(url);
return false;
}
+
+/** Force a browser download of a blob under the given filename (no preview tab). */
+export function saveBlob(blob: Blob, filename: string) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ // Delay revoke so the download has time to start (esp. for rapid multi-saves).
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
+}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index f76dbbde2..bae6361fb 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -558,6 +558,7 @@ export const URL_CONSTANTS = {
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
+ ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
},
WAREHOUSE_INVOICES: {
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
index da1bfb867..56ef4876b 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() {
});
}
+/** Live per-item fee accrual (storage/demurrage) with alerts. */
+export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
+ return useQuery({
+ queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
+ queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
+ });
+}
+
export function useCreateZone() {
const qc = useQueryClient();
return useMutation({
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
index 6f4c16594..1f632c1e7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
+import { AccrualDashboard } from '@/components/warehouses';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
+
+
+ Accruing now
+
+
+
+
(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }),
}),
+ accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
+ apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
+ params: cleanParams({ billingCurrency }),
+ }),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index eb04c39bc..7ef649bea 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1115,3 +1115,30 @@ export interface WarehouseOpsStats {
trucksOnSite: number;
itemsAging: number;
}
+
+export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
+
+/** One item's live fee accrual for the accrual dashboard. */
+export interface AccrualDashboardRow {
+ inventoryId: string;
+ status: string;
+ bookingId: string | null;
+ companyId: string | null;
+ bookingReference: string | null;
+ customerName: string | null;
+ warehouseCode: string | null;
+ zoneCode: string | null;
+ receivedAt: string | null;
+ currency: string;
+ accruedAmount: number;
+ freeDaysLeft: number | null;
+ charging: boolean;
+ alert: AccrualAlert;
+ breakdown: Array<{
+ type: string;
+ amount: number;
+ freeDays: number;
+ elapsedDays: number;
+ chargeableDays: number;
+ }>;
+}
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 1128b0883..850081ea1 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -209,6 +209,20 @@ export const bookingsService = {
);
return data;
},
+ downloadBookingGrnDocument: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
+ { responseType: "blob" },
+ );
+ return data;
+ },
+ downloadBookingReleaseDocument: async (bookingId: string): Promise => {
+ const { data } = await client.get(
+ `/api/warehouse-inventory/bookings/${bookingId}/release-document`,
+ { responseType: "blob" },
+ );
+ return data;
+ },
tracking: async (id: string): Promise => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
From a3673065654076b979fe2457e66a88ec27ca21dc Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 09:35:21 +0000
Subject: [PATCH 03/67] feat(warehouse): accrual acknowledge/snooze + zone
weight fix, handover signer, portal delivery
Accrual dashboard:
- warehouse_accrual_acks table (migration 2140) + acknowledge/unacknowledge
endpoints; dashboard rows carry acknowledged/snoozeUntil, acked items sink
and are skipped by the alert cron. Row menu: mark reviewed / snooze 3d / 7d /
un-acknowledge; acked rows dimmed with a "Reviewed" badge.
- Fix zone weight occupancy: normalise inventory kg vs zone-capacity tonnes.
Handover (rode along, shared files):
- Require signer full name on delivery handover (signature optional);
migration 2130 adds signer_name.
Portal delivery/docs (rode along, shared files):
- Approve-delivery name capture, booking-scoped GRN/release docs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../2130000000000-AddHandoverSignerName.ts | 23 +++++
.../2140000000000-CreateAccrualAcks.ts | 29 +++++++
.../warehouses/dto/acknowledge-accrual.dto.ts | 18 ++++
.../warehouses/dto/approve-delivery.dto.ts | 11 +++
.../entities/booking-handover.entity.ts | 4 +
.../modules/warehouses/handover.service.ts | 12 ++-
.../warehouses/warehouse-fee.service.ts | 56 +++++++++++-
.../warehouse-inventory.controller.ts | 10 ++-
.../warehouses/warehouse-inventory.service.ts | 33 ++++---
.../warehouses/warehouse-rules.controller.ts | 20 +++++
.../warehouses/AccrualDashboard.tsx | 86 +++++++++++++++++--
.../backoffice/src/constants/URLS.ts | 2 +
.../src/services/warehouse.service.ts | 4 +
.../backoffice/src/types/warehouse.ts | 2 +
.../components/DocumentsTab.tsx | 54 ++++++++++++
.../delivery/ApproveDeliveryModal.tsx | 21 +++--
.../portal/src/services/api.ts | 4 +-
.../portal/src/services/bookings.service.ts | 6 +-
18 files changed, 362 insertions(+), 33 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts
create mode 100644 apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts
create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts
diff --git a/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts
new file mode 100644
index 000000000..4e4c5f5fc
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts
@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * The person who signs off a handover must record their full name (a signature
+ * is optional, especially for self-haul). Stored per handover record.
+ */
+export class AddHandoverSignerName2130000000000 implements MigrationInterface {
+ name = "AddHandoverSignerName2130000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.booking_handovers
+ ADD COLUMN IF NOT EXISTS signer_name varchar(160)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.booking_handovers
+ DROP COLUMN IF EXISTS signer_name
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts
new file mode 100644
index 000000000..5e13a6e3d
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts
@@ -0,0 +1,29 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
+ * accrual as reviewed (optionally snoozed until a date) so it stops nudging and
+ * drops down the accrual dashboard. One row per inventory item.
+ */
+export class CreateAccrualAcks2140000000000 implements MigrationInterface {
+ name = "CreateAccrualAcks2140000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ inventory_id uuid NOT NULL UNIQUE,
+ acknowledged_by uuid,
+ acknowledged_at timestamptz NOT NULL DEFAULT now(),
+ snooze_until timestamptz,
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+ )
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts
new file mode 100644
index 000000000..6b3697f03
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts
@@ -0,0 +1,18 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
+
+/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
+export class AcknowledgeAccrualDto {
+ @ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ @Max(90)
+ snoozeDays?: number;
+
+ @ApiPropertyOptional({ description: 'Optional reason / note.' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ note?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts
new file mode 100644
index 000000000..2ef499201
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
+
+/** The customer approving a handover must record their full name (signature optional). */
+export class ApproveDeliveryDto {
+ @ApiProperty({ description: 'Full name of the person approving delivery.' })
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(160)
+ signerName!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts
index f5a730ea8..a0a7ad70f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts
@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
+ /** Full name of the person who signed off the handover (required at sign time). */
+ @Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
+ signerName?: string | null;
+
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts
index 48ab3ac48..d50b27150 100644
--- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts
@@ -183,12 +183,20 @@ export class HandoverService {
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
- async signForBooking(bookingId: string, userId?: string | null): Promise {
+ async signForBooking(
+ bookingId: string,
+ userId?: string | null,
+ signerName?: string | null,
+ ): Promise {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
- { signedAt: new Date(), signedByUserId: userId ?? null },
+ {
+ signedAt: new Date(),
+ signedByUserId: userId ?? null,
+ signerName: signerName?.trim() || null,
+ },
);
}
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 720f30766..92aa36b85 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
@@ -46,6 +46,9 @@ export interface AccrualDashboardRow {
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
+ /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
+ acknowledged: boolean;
+ snoozeUntil: string | null;
breakdown: Array<{
type: FeeRuleType;
amount: number;
@@ -116,7 +119,9 @@ export class WarehouseFeeService {
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
async sendAccrualAlerts(): Promise {
try {
- const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK');
+ const alerts = (await this.accrualDashboard()).filter(
+ (r) => r.alert !== 'OK' && !r.acknowledged,
+ );
if (!alerts.length) return;
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
@@ -549,6 +554,14 @@ export class WarehouseFeeService {
ORDER BY inv.created_at ASC`,
);
+ const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
+ await this.dataSource.query(
+ `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
+ FROM freight.warehouse_accrual_acks`,
+ );
+ const now = new Date();
+ const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
+
const rows = await Promise.all(
items.map(async (it): Promise => {
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
@@ -581,6 +594,10 @@ export class WarehouseFeeService {
freeDaysLeft,
charging,
alert,
+ acknowledged:
+ acks.has(it.id) &&
+ (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
+ snoozeUntil: acks.get(it.id) ?? null,
breakdown: previews.map((p) => ({
type: p.ruleType,
amount: p.amount,
@@ -593,8 +610,43 @@ export class WarehouseFeeService {
);
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
+ // Acknowledged items sink to the bottom; among the rest, worst alert first.
return rows.sort(
- (a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount,
+ (a, b) =>
+ Number(a.acknowledged) - Number(b.acknowledged) ||
+ rank(a.alert) - rank(b.alert) ||
+ b.accruedAmount - a.accruedAmount,
+ );
+ }
+
+ /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
+ async acknowledgeAccrual(
+ inventoryId: string,
+ opts: { snoozeDays?: number; note?: string; userId?: string } = {},
+ ): Promise {
+ const snoozeUntil =
+ opts.snoozeDays && opts.snoozeDays > 0
+ ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
+ : null;
+ await this.dataSource.query(
+ `INSERT INTO freight.warehouse_accrual_acks
+ (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
+ VALUES ($1, $2, now(), $3, $4, now())
+ ON CONFLICT (inventory_id) DO UPDATE
+ SET acknowledged_by = EXCLUDED.acknowledged_by,
+ acknowledged_at = now(),
+ snooze_until = EXCLUDED.snooze_until,
+ note = EXCLUDED.note,
+ updated_at = now()`,
+ [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
+ );
+ }
+
+ /** Remove an acknowledgement so the item re-surfaces for alerts. */
+ async unacknowledgeAccrual(inventoryId: string): Promise {
+ await this.dataSource.query(
+ `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
+ [inventoryId],
);
}
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 f45abe507..055c9bac9 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
@@ -11,6 +11,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
+import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -348,12 +349,17 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
- @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
+ @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
+ @Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
) {
- return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
+ return this.inventoryService.approveDeliveryForBooking(
+ bookingId,
+ req.user?.id ?? req.user?.sub,
+ dto.signerName,
+ );
}
@Get('bookings/:bookingId/handovers')
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 8bce58f01..1c1be66ec 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
@@ -493,14 +493,17 @@ export class WarehouseInventoryService {
return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
+ // Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
+ // used weight to tonnes before comparing so weight occupancy is correct.
+ const usedWeightTons = r.usedWeight / 1000;
const byWeight =
- capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
+ capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
- // Prefer container-count occupancy (unit-consistent). Weight capacity is
- // tonnes while inventory weight is kg, so weight% is only a rough fallback.
+ // Container zones use item-count occupancy; bulk zones (no container cap)
+ // fall back to the now unit-correct weight occupancy.
const pct = byItems ?? byWeight;
return {
id: r.id,
@@ -3171,16 +3174,20 @@ export class WarehouseInventoryService {
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
+ signerName?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
-
- const signature = await this.signatures.getForUser(userId);
- if (!signature?.signatureImageUrl) {
- throw new BadRequestException('Please save your signature before approving delivery');
+ const name = signerName?.trim();
+ if (!name) {
+ throw new BadRequestException('Please enter your full name to approve delivery');
}
+ // A saved signature is applied when available; otherwise the typed full name
+ // is the record of who approved (self-haul customers may have no signature).
+ const signature = await this.signatures.getForUser(userId).catch(() => null);
+
const [item]: Array<{
id: string;
warehouseId: string | null;
@@ -3215,8 +3222,8 @@ export class WarehouseInventoryService {
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
- signerDisplayName: signature.signerDisplayName,
- signatureImageUrl: signature.signatureImageUrl,
+ signerDisplayName: name,
+ signatureImageUrl: signature?.signatureImageUrl ?? null,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -3231,8 +3238,8 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
- description: `Customer approved delivery as ${signature.signerDisplayName}`,
- performedBy: signature.signerDisplayName,
+ description: `Customer approved delivery as ${name}`,
+ performedBy: name,
},
manager,
);
@@ -3240,13 +3247,13 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
- await this.handover.signForBooking(bookingId, userId);
+ await this.handover.signForBooking(bookingId, userId, name);
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
- signerDisplayName: signature.signerDisplayName,
+ signerDisplayName: name,
};
}
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 1fecf9392..19788e027 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
@@ -7,6 +7,7 @@ import {
UpdateAllocationRuleDto,
} from './dto/allocation-rule.dto';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
+import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeService } from './warehouse-fee.service';
@@ -83,6 +84,25 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency);
}
+ @Post('warehouse-fees/accrual/:inventoryId/acknowledge')
+ @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
+ acknowledgeAccrual(
+ @Param('inventoryId', ParseUUIDPipe) inventoryId: string,
+ @Body() dto: AcknowledgeAccrualDto,
+ ) {
+ return this.feeService.acknowledgeAccrual(inventoryId, {
+ snoozeDays: dto.snoozeDays,
+ note: dto.note,
+ });
+ }
+
+ @Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
+ @HttpCode(204)
+ @ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
+ unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
+ return this.feeService.unacknowledgeAccrual(inventoryId);
+ }
+
@Get('warehouse-inventory/:id/fee-preview')
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx
index 2ac3fa2f9..5170b345d 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx
@@ -1,8 +1,11 @@
import { useMemo } from 'react';
-import { Badge, Card, Group, Loader, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
-import { AlertTriangle, Clock, DollarSign } from 'lucide-react';
+import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { useAccrualDashboard } from '@/hooks/useWarehouses';
+import { warehouseService } from '@/services/warehouse.service';
+import { useToast } from '@/hooks/use-toast';
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
const ALERT_META: Record = {
@@ -27,6 +30,29 @@ function freeDaysLabel(row: AccrualDashboardRow): string {
*/
export function AccrualDashboard() {
const { data: rows = [], isLoading } = useAccrualDashboard();
+ const { toast } = useToast();
+ const qc = useQueryClient();
+
+ const refresh = () =>
+ qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
+
+ const ack = useMutation({
+ mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
+ warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
+ onSuccess: (_r, v) => {
+ toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
+ void refresh();
+ },
+ onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
+ });
+ const unack = useMutation({
+ mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
+ onSuccess: () => {
+ toast({ title: 'Acknowledgement removed' });
+ void refresh();
+ },
+ onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
+ });
const summary = useMemo(() => {
const currency = rows[0]?.currency ?? 'USD';
@@ -86,13 +112,15 @@ export function AccrualDashboard() {
Accrued
Free days
Alert
+
{rows.map((row) => {
const meta = ALERT_META[row.alert];
+ const busy = ack.isPending || unack.isPending;
return (
-
+
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
@@ -120,9 +148,55 @@ export function AccrualDashboard() {
-
- {meta.label}
-
+ {row.acknowledged ? (
+ }>
+ Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
+
+ ) : (
+
+ {meta.label}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {row.acknowledged ? (
+ }
+ onClick={() => unack.mutate(row.inventoryId)}
+ >
+ Un-acknowledge
+
+ ) : (
+ <>
+ }
+ onClick={() => ack.mutate({ id: row.inventoryId })}
+ >
+ Mark reviewed
+
+ }
+ onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
+ >
+ Snooze 3 days
+
+ }
+ onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
+ >
+ Snooze 7 days
+
+ >
+ )}
+
+
);
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index bae6361fb..618d0ba30 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -559,6 +559,8 @@ export const URL_CONSTANTS = {
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
+ ACCRUAL_ACK: (inventoryId: string) =>
+ `/warehouse-fees/accrual/${inventoryId}/acknowledge`,
},
WAREHOUSE_INVOICES: {
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 0ab883ff8..febde7f85 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -430,6 +430,10 @@ export const warehouseService = {
apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }),
}),
+ acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
+ apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
+ unacknowledgeAccrual: (inventoryId: string) =>
+ apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 7ef649bea..7a88b136e 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -1134,6 +1134,8 @@ export interface AccrualDashboardRow {
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
+ acknowledged: boolean;
+ snoozeUntil: string | null;
breakdown: Array<{
type: string;
amount: number;
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
index a24f34763..fe7f58e62 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
+import toast from "react-hot-toast";
+
+import { bookingsService } from "@/services/bookings.service";
+import { saveBlob } from "@/utils/download";
import { fmtDate } from "../utils";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
const company = contract?.company;
+ // One-click warehouse-document bundle: GRN + gate clearance + handover.
+ const [bundleBusy, setBundleBusy] = useState(false);
+ const downloadWarehouseDocuments = async () => {
+ setBundleBusy(true);
+ const ref = booking.reference ?? booking.id;
+ const jobs: Array<{ name: string; fn: () => Promise }> = [
+ { name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) },
+ {
+ name: `gate-clearance-${ref}.pdf`,
+ fn: () => bookingsService.downloadBookingReleaseDocument(booking.id),
+ },
+ {
+ name: `handover-${ref}.pdf`,
+ fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
+ },
+ ];
+ let saved = 0;
+ for (const job of jobs) {
+ try {
+ saveBlob(await job.fn(), job.name);
+ saved += 1;
+ } catch {
+ // Document not available for this booking yet — skip it.
+ }
+ }
+ setBundleBusy(false);
+ if (saved === 0) {
+ toast.error("No warehouse documents are available for this booking yet.");
+ } else {
+ toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`);
+ }
+ };
+
return (
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
)}
+ {/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
+
+ Warehouse documents
+
+ Goods Received Note, gate clearance / release order and handover — download all
+ available documents for this booking in one click.
+
+ }
+ color="edr-green"
+ loading={bundleBusy}
+ onClick={downloadWarehouseDocuments}
+ >
+ Download documents
+
+
+
{!hasContract && otherBookingFiles.length === 0 && (
}>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx
index a3723b650..90e95369f 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx
@@ -1,4 +1,4 @@
-import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
+import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState(null);
+ const [signerName, setSignerName] = useState("");
const {
data: docBlob,
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
}>
- Review the handover document below. Approving applies your saved signature
- and confirms you received the goods.
+ Review the handover document below, then type your full name to sign and
+ confirm you received the goods. Your saved signature is applied automatically
+ if you have one.
@@ -151,6 +153,15 @@ export function ApproveDeliveryModal({
/>
)}
+ setSignerName(e.currentTarget.value)}
+ disabled={busy}
+ />
+
Cancel
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
color="edr-green"
leftSection={ }
loading={busy}
- disabled={isLoading || isError}
- onClick={() => approve.mutate({ id: bookingId })}
+ disabled={isLoading || isError || !signerName.trim()}
+ onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
>
Approve & sign delivery
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 6cd606b2e..6f4527c0b 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -387,10 +387,10 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId),
),
- approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
+ approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
"bookings",
"approveDelivery",
- ({ id }) => bookingsService.approveDelivery(id),
+ ({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
),
getBookableSchedules: endpoint<
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 850081ea1..a60fe4275 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -373,9 +373,13 @@ export const bookingsService = {
return data.data ?? data;
},
- approveDelivery: async (id: string): Promise => {
+ approveDelivery: async (
+ id: string,
+ signerName: string,
+ ): Promise => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
+ { signerName },
);
return data.data ?? data;
},
From 65f6015c5e148ada9e2d8696abb7609fec37f216 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 12:52:49 +0000
Subject: [PATCH 04/67] feat(warehouse): guard warehouse/inventory/fee
endpoints with RBAC permissions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Apply JwtGuard + FreightPermissionGuard (via @BookingStaff) to 83 staff
endpoints across the 8 warehouse controllers, using existing
edr_freight_app:warehouse* permissions: warehouses/yards/zones,
inventory receive/move/load/unload/dispatch/gate-pass/release/deliver/inspect
(incl. import & export queues), allocation + fee rules (demurrage/storage/
double-handling), accrual dashboard + acknowledge, and fee invoices.
Customer-portal endpoints (booking-scoped documents, approve-delivery,
portal fee-invoice view/document/receipt/pay-online) are intentionally left
unguarded — they need a customer-ownership guard, not staff permissions.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouse-inspection.controller.ts | 6 +++
.../warehouse-inventory.controller.ts | 48 +++++++++++++++++++
.../warehouse-invoice.controller.ts | 8 ++++
.../warehouse-loadings.controller.ts | 3 ++
.../warehouses/warehouse-rules.controller.ts | 16 +++++++
.../warehouses/warehouse-yards.controller.ts | 6 +++
.../warehouses/warehouse-zones.controller.ts | 4 ++
.../warehouses/warehouses.controller.ts | 8 ++++
8 files changed, 99 insertions(+)
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
index 533b0c6ae..7bd56e593 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
@@ -12,6 +12,8 @@ import {
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionService } from './warehouse-inspection.service';
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
@Controller()
+@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}
@Post('warehouse-inventory/:inventoryId/inspection-reports')
+ @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
create(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
}
@Patch('warehouse-inspection-reports/:id')
+ @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@ApiOperation({ summary: 'Update an inspection report' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
return this.inspectionService.update(id, dto);
}
@Post('warehouse-inspection-reports/:id/attachments')
+ @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload inspection images / documents' })
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 055c9bac9..8b14e6cdc 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
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -30,54 +32,63 @@ export class WarehouseInventoryController {
) {}
@Get()
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' })
findAll(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findAll(filter);
}
@Get('ready-for-loading')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' })
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findReadyForLoading(filter);
}
@Get('inquiry')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
return this.inventoryService.inquiry(filter);
}
@Get('arrival-queue')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
arrivalQueue() {
return this.inventoryService.arrivalQueue();
}
@Get('ops-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
opsStats() {
return this.inventoryService.opsStats();
}
@Get('zone-occupancy')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId);
}
@Post('auto-unload-arrived')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {
return this.inventoryService.autoUnloadArrived();
}
@Post('auto-load-ready')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
autoLoadReady() {
return this.inventoryService.autoLoadReady();
}
@Get('eligible-bookings')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
eligibleBookings(@Query('direction') direction?: string) {
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
@@ -85,6 +96,7 @@ 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) {
return this.inventoryService.bulkReceive(dto);
@@ -92,36 +104,42 @@ export class WarehouseInventoryController {
@Get('ready-to-load-export')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
readyToLoadExport() {
return this.inventoryService.readyToLoadExport();
}
@Get('received-export')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
receivedExport() {
return this.inventoryService.receivedExport();
}
@Get('loaded-export')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@@ -131,18 +149,21 @@ export class WarehouseInventoryController {
}
@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);
}
@Post('bulk-mark-inspected')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto);
}
@Post('bookings/:bookingId/unload')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -152,24 +173,28 @@ 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);
}
@Get('import/arrive-queue')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
importArriveQueue() {
return this.scheduling.importArriveQueue();
}
@Get('import/trains/:scheduleId/items')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.importTrainDetail(scheduleId);
}
@Post('import/auto-unload-arrived-bookings')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string;
@@ -186,12 +211,14 @@ export class WarehouseInventoryController {
}
@Get('import/unloaded-queue')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue();
}
@Get('export/djibouti-arrival-queue')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
exportDjiboutiArrivalQueue(
@Query('scheduleId') scheduleId?: string,
@@ -210,102 +237,119 @@ export class WarehouseInventoryController {
}
@Get('export/djibouti-trains/:scheduleId/items')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
}
@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);
}
@Get('import/pickup-ready-queue')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue();
}
@Get('loadable-wagons')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {
return this.scheduling.listLoadableWagons();
}
@Get('booking/:bookingId/schedule')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.scheduling.getBookingSchedule(bookingId);
}
@Post('receive')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
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) {
return this.inventoryService.reserve(dto);
}
@Get(':id/movements')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory movement history' })
movements(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findMovements(id);
}
@Get(':id/activity')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory activity log' })
activity(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findActivity(id);
}
@Get(':id/loadings')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Loading records for an inventory item' })
loadings(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findLoadingsByInventory(id);
}
@Post(':id/move')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto);
}
@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);
}
@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);
}
@Post(':id/load')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
return this.inventoryService.load(id, dto);
}
@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);
}
@Post(':id/release')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
return this.inventoryService.release(id, dto);
}
@Get(':id/release-document')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
@@ -316,6 +360,7 @@ export class WarehouseInventoryController {
}
@Get('customer-truck-exit-paper/:assignmentId')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@@ -329,6 +374,7 @@ export class WarehouseInventoryController {
}
@Get(':id/grn-document')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
@@ -417,12 +463,14 @@ 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) {
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);
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 a1c5db837..635ca1a10 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
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
@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) {
return this.invoiceService.generateForInventory(id, dto);
}
@Post('last-mile/:id/generate-truck-detention-invoice')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string,
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-inventory/:id/fee-invoices')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForInventory(id);
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
findAll(
@Query('status') status?: string,
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
}
@Patch('warehouse-fee-invoices/:id/cancel')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.cancel(id);
}
@Post('warehouse-fee-invoices/:id/pay')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto);
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts
index aab8e18b2..32860082e 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts
@@ -1,11 +1,14 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-loadings')
@ApiBearerAuth()
@Controller('warehouse-loadings')
+@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
export class WarehouseLoadingsController {
constructor(private readonly inventoryService: WarehouseInventoryService) {}
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 19788e027..7a5c53d38 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
@@ -1,6 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
AllocationPreviewDto,
CreateAllocationRuleDto,
@@ -22,18 +24,21 @@ export class WarehouseRulesController {
// ── Allocation rules ───────────────────────────────────────────────────────
@Get('warehouse-allocation-rules')
+ @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'List warehouse allocation rules' })
listAllocationRules() {
return this.allocationService.listRules();
}
@Post('warehouse-allocation-rules')
+ @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
return this.allocationService.createRule(dto);
}
@Patch('warehouse-allocation-rules/:id')
+ @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
return this.allocationService.updateRule(id, dto);
@@ -41,12 +46,14 @@ export class WarehouseRulesController {
@Delete('warehouse-allocation-rules/:id')
@HttpCode(204)
+ @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
return this.allocationService.deleteRule(id);
}
@Post('warehouse-allocation/preview')
+ @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
previewAllocation(@Body() dto: AllocationPreviewDto) {
return this.allocationService.resolveLocation(dto);
@@ -54,18 +61,21 @@ export class WarehouseRulesController {
// ── Fee rules ────────────────────────────────────────────────────────────────
@Get('warehouse-fee-rules')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
listFeeRules() {
return this.feeService.listRules();
}
@Post('warehouse-fee-rules')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
createFeeRule(@Body() dto: CreateFeeRuleDto) {
return this.feeService.createRule(dto);
}
@Patch('warehouse-fee-rules/:id')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Update a fee rule' })
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
return this.feeService.updateRule(id, dto);
@@ -73,18 +83,21 @@ export class WarehouseRulesController {
@Delete('warehouse-fee-rules/:id')
@HttpCode(204)
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
@ApiOperation({ summary: 'Delete a fee rule' })
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
return this.feeService.deleteRule(id);
}
@Get('warehouse-fees/accrual-dashboard')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
return this.feeService.accrualDashboard(billingCurrency);
}
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
acknowledgeAccrual(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@@ -98,12 +111,14 @@ export class WarehouseRulesController {
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
@HttpCode(204)
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
return this.feeService.unacknowledgeAccrual(inventoryId);
}
@Get('warehouse-inventory/:id/fee-preview')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(
@Param('id', ParseUUIDPipe) id: string,
@@ -113,6 +128,7 @@ export class WarehouseRulesController {
}
@Get('last-mile/:id/truck-detention-preview')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
truckDetentionPreview(
@Param('id', ParseUUIDPipe) id: string,
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
index c14cea7a4..fdfbc36be 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYardsService } from './warehouse-yards.service';
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards')
@ApiBearerAuth()
@Controller('warehouse-yards')
+@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
export class WarehouseYardsController {
constructor(
private readonly yardsService: WarehouseYardsService,
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
}
@Patch(':id')
+ @BookingStaff(FREIGHT_PERMS.warehouseYards.update)
@ApiOperation({ summary: 'Update warehouse yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
return this.yardsService.update(id, dto);
}
@Get(':yardId/zones')
+ @BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@ApiOperation({ summary: 'List zones within a yard' })
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
return this.zonesService.findByYard(yardId);
}
@Post(':yardId/zones')
+ @BookingStaff(FREIGHT_PERMS.warehouseZones.create)
@ApiOperation({ summary: 'Create a zone within a yard' })
createZone(
@Param('yardId', ParseUUIDPipe) yardId: string,
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
index 7d51feac3..b0371cbcc 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
@@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
@Controller('warehouse-zones')
+@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
}
@Patch(':id')
+ @BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({ summary: 'Update warehouse zone' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
return this.zonesService.update(id, dto);
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
index bb7702603..63c40de94 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { BookingStaff } from '../../common/booking-guards';
+import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
@Controller('warehouses')
+@BookingStaff(FREIGHT_PERMS.warehouses.view)
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
@@ -26,12 +29,14 @@ export class WarehousesController {
}
@Get('dashboard')
+ @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() {
return this.dashboardService.getDashboard();
}
@Post()
+ @BookingStaff(FREIGHT_PERMS.warehouses.create)
@ApiOperation({ summary: 'Create warehouse' })
create(@Body() dto: CreateWarehouseDto) {
return this.warehousesService.create(dto);
@@ -44,18 +49,21 @@ export class WarehousesController {
}
@Patch(':id')
+ @BookingStaff(FREIGHT_PERMS.warehouses.update)
@ApiOperation({ summary: 'Update warehouse' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
return this.warehousesService.update(id, dto);
}
@Get(':warehouseId/yards')
+ @BookingStaff(FREIGHT_PERMS.warehouseYards.view)
@ApiOperation({ summary: 'List yards within a warehouse' })
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
return this.yardsService.findByWarehouse(warehouseId);
}
@Post(':warehouseId/yards')
+ @BookingStaff(FREIGHT_PERMS.warehouseYards.create)
@ApiOperation({ summary: 'Create a yard within a warehouse' })
createYard(
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
From ddd35012399429ddf9e5d88ec2a6a4364cb70a78 Mon Sep 17 00:00:00 2001
From: Yonas Tewabe
Date: Tue, 14 Jul 2026 16:00:04 +0300
Subject: [PATCH 05/67] Update docker-compose.yaml
---
docker-compose.yaml | 47 +++++----------------------------------------
1 file changed, 5 insertions(+), 42 deletions(-)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index d3c89c4a9..c0b58b44c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -5,9 +5,6 @@
# Build: DOCKER_BUILDKIT=1 docker compose build
# Run: docker compose up -d
services:
- # Message broker for payment event delivery (payment-api -> passenger/freight).
- # Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
- # In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
freight-api:
build:
context: .
@@ -21,9 +18,6 @@ services:
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
- # Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
- # HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
- # freight DB; the /gps REST API stays in freight-api. Never runs migrations.
gps-tracker:
build:
context: .
@@ -33,14 +27,12 @@ services:
depends_on:
- freight-api
ports:
- # Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
- "${GT06_TCP_PORT:-5023}:5023"
environment:
GT06_TCP_PORT: "5023"
GT06_TCP_HOST: "0.0.0.0"
env_file:
- # Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME).
- - apps/edr-freight-api/.env
+ - apps/edr-gps-tracker/.env
restart: unless-stopped
passenger-api:
@@ -54,17 +46,7 @@ services:
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
restart: unless-stopped
- healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 60s
- deploy:
- resources:
- limits:
- cpus: '1.0'
- memory: 1G
+
freight-portal:
build:
context: .
@@ -80,6 +62,7 @@ services:
- npmrc
ports:
- "${FREIGHT_PORTAL_PORT:-5173}:80"
+
freight-backoffice:
build:
context: .
@@ -95,6 +78,7 @@ services:
- npmrc
ports:
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
+
passenger-portal:
build:
context: .
@@ -110,17 +94,7 @@ services:
env_file:
- apps/edr-passenger-web/portal/.env
restart: unless-stopped
- healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 60s
- deploy:
- resources:
- limits:
- cpus: '0.5'
- memory: 512M
+
passenger-backoffice:
build:
context: .
@@ -136,17 +110,6 @@ services:
env_file:
- apps/edr-passenger-web/backoffice/.env
restart: unless-stopped
- healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 60s
- deploy:
- resources:
- limits:
- cpus: '0.5'
- memory: 512M
payment-api:
build:
From ed901777a60e8c76830070f52ab4039d1bee89d6 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Tue, 14 Jul 2026 16:00:39 +0300
Subject: [PATCH 06/67] Price issue on sign in path addressed, Djibouti side
pricing updates added
---
.../src/modules/bookings/bookings.service.ts | 2 +-
.../fare-engine/fare-engine.service.ts | 28 +-
.../modules/schedules/schedules.controller.ts | 34 +
.../modules/schedules/schedules.service.ts | 59 ++
.../seat-classes/seat-classes.controller.ts | 6 +-
.../seat-classes/seat-classes.service.ts | 10 +-
.../backoffice/src/app/classes/page.tsx | 1 -
.../src/app/tariff-rates/BaggageTab.tsx | 134 ++++
.../src/app/tariff-rates/OverridesTab.tsx | 272 +++++++
.../src/app/tariff-rates/RateModal.tsx | 250 +++++++
.../src/app/tariff-rates/TariffTab.tsx | 172 +++++
.../src/app/tariff-rates/constants.ts | 31 +
.../backoffice/src/app/tariff-rates/hooks.ts | 106 +++
.../backoffice/src/app/tariff-rates/page.tsx | 665 +++---------------
.../backoffice/src/app/tariff-rates/types.ts | 47 ++
15 files changed, 1237 insertions(+), 580 deletions(-)
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts
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 114c0cc9d..7f09ffa49 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -857,7 +857,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: resolvedTotalMinor / 100,
+ totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
index dd4e15b9c..fd3d75401 100644
--- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
+++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts
@@ -103,6 +103,18 @@ export class FareEngineService {
},
});
+ // Route-level fare override: checked after segment (most specific) but before
+ // schedule-scoped rules and the global seat-class tariff (least specific).
+ const routeFareOverride = segmentOverride ? null : await this.prisma.routeFareRule.findFirst({
+ where: {
+ routeId: route.id,
+ seatClassId: nationalitySeatClass.id,
+ validFrom: { lte: now },
+ OR: [{ validUntil: null }, { validUntil: { gte: now } }],
+ },
+ orderBy: { validFrom: 'desc' },
+ });
+
if (segmentOverride) {
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
@@ -110,6 +122,20 @@ export class FareEngineService {
}
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
+ } else if (routeFareOverride) {
+ // Stored as a per-km rate (same unit as SeatClass.baseFareMinor × 100).
+ // Insurance factor and USD→ETB conversion are applied identically to the
+ // global seat-class formula so the override is a pure rate substitution.
+ const ratePerKmEtb = routeFareOverride.baseFareMinor / 100;
+ insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
+ ? nationalitySeatClass.insuranceFeeMinor / 100 : 1;
+ usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
+ ratePerKmMinor = routeFareOverride.baseFareMinor;
+ baseFarePerPassengerMinor = Math.round(
+ totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
+ );
+ fareSource = 'ROUTE_FARE_OVERRIDE';
+ insuranceAlreadyInBase = true;
} else if (fareRule?.tripId) {
baseFarePerPassengerMinor = fareRule.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
@@ -172,7 +198,7 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`,
- `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
+ `Rate per km: ${routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor} minor → ${(routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor) / 100} ETB/km${routeFareOverride ? ' [ROUTE OVERRIDE]' : ''}`,
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
index 9fc2ba851..cbd2eb4b2 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
@@ -69,6 +69,40 @@ export class SchedulesController {
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
+ // Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
+ @Delete('routes/fare-rules/:id')
+ @PassengerAdmin()
+ @ApiBearerAuth('IAM-auth')
+ @ApiOperation({ summary: 'Delete a route-level fare override' })
+ @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
+ deleteRouteFareRule(@Param('id') id: string) {
+ return this.service.deleteRouteFareRule(id);
+ }
+
+ @Patch('routes/fare-rules/:id')
+ @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Update a route-level fare override' })
+ @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
+ updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
+ return this.service.updateRouteFareRule(id, dto);
+ }
+
+ @Get('routes/:routeId/fare-rules')
+ @IsPublic()
+ @ApiOperation({ summary: 'List route-level fare overrides for a route' })
+ @ApiParam({ name: 'routeId', description: 'Route UUID' })
+ listRouteFareRules(@Param('routeId') routeId: string) {
+ return this.service.listRouteFareRules(routeId);
+ }
+
+ @Post('routes/:routeId/fare-rules')
+ @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @ApiOperation({ summary: 'Create a route-level fare override' })
+ @ApiParam({ name: 'routeId', description: 'Route UUID' })
+ createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
+ return this.service.createRouteFareRule({ ...dto, routeId });
+ }
+
@Get('routes/:routeId/segment-fares')
@IsPublic()
@ApiOperation({ summary: 'List all segment fare rules for a route' })
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
index 92a0a66db..06bb4c2dc 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
@@ -676,4 +676,63 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
+
+ // ── Route Fare Rule Overrides ──────────────────────────────────────────────
+
+ listRouteFareRules(routeId: string) {
+ return this.prisma.routeFareRule.findMany({
+ where: { routeId },
+ include: { seatClass: true, route: true },
+ orderBy: { createdAt: 'desc' },
+ });
+ }
+
+ async createRouteFareRule(dto: {
+ routeId: string;
+ seatClassId: string;
+ passengerCategory?: string;
+ baseFareMinor: number;
+ validFrom: string;
+ validUntil?: string;
+ }) {
+ const [route, seatClass] = await Promise.all([
+ this.prisma.route.findUnique({ where: { id: dto.routeId } }),
+ this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }),
+ ]);
+ if (!route) throw new NotFoundException('Route not found');
+ if (!seatClass) throw new NotFoundException('Seat class not found');
+ return this.prisma.routeFareRule.create({
+ data: {
+ routeId: dto.routeId,
+ seatClassId: dto.seatClassId,
+ passengerCategory: (dto.passengerCategory as any) ?? 'ADULT',
+ baseFareMinor: dto.baseFareMinor,
+ validFrom: parseEthiopianTime(dto.validFrom),
+ validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null,
+ },
+ include: { seatClass: true, route: true },
+ });
+ }
+
+ async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
+ const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
+ if (!rule) throw new NotFoundException('Route fare rule not found');
+ return this.prisma.routeFareRule.update({
+ where: { id },
+ data: {
+ ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
+ ...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }),
+ ...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }),
+ ...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }),
+ },
+ include: { seatClass: true, route: true },
+ });
+ }
+
+ async deleteRouteFareRule(id: string) {
+ const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
+ if (!rule) throw new NotFoundException('Route fare rule not found');
+ await this.prisma.routeFareRule.delete({ where: { id } });
+ return { deleted: true, id };
+ }
}
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
index 4eb6c212b..f6cf3c76a 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
@@ -1,4 +1,4 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
@@ -49,5 +49,7 @@ export class SeatClassesController {
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Seat class deleted' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
- deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); }
+ deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
+ return this.service.deleteSeatClass(id, cascade === 'true');
+ }
}
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
index 63f5e1f29..99c67a665 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
@@ -45,7 +45,7 @@ export class SeatClassesService {
}
}
- async deleteSeatClass(id: string) {
+ async deleteSeatClass(id: string, cascade = false) {
const sc = await this.prisma.seatClass.findUnique({
where: { id },
include: {
@@ -59,11 +59,17 @@ export class SeatClassesService {
(sc as any)._count.routeFareRules +
(sc as any)._count.segmentFares;
- if (totalFareRules > 0)
+ if (totalFareRules > 0 && !cascade)
throw new DeleteOperationException('Seat Class', sc.name, [
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
]);
+ if (cascade) {
+ await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
+ await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
+ await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
+ }
+
return this.prisma.seatClass.delete({ where: { id } });
}
}
diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
index f5aed275e..af2cab29b 100644
--- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx
@@ -311,7 +311,6 @@ export default function ClassesPage() {
step="0.01"
placeholder="e.g., 25.00"
/>
- Flat fee per passenger (e.g., travel insurance)
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
new file mode 100644
index 000000000..5723197c9
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { useState } from 'react';
+import { Edit, Trash2 } from 'lucide-react';
+import DataTable from '@/components/ui/DataTable';
+import Modal from '@/components/ui/Modal';
+import ActionButton from '@/components/ui/ActionButton';
+import ConfirmDialog from '@/components/ui/ConfirmDialog';
+import { useBaggageMutations } from './hooks';
+import type { SeatClass, BaggageAllowance } from './types';
+
+interface Props {
+ allClasses: SeatClass[];
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
+ const { allowances, isLoading, create, update, remove } = useBaggageMutations();
+ const [form, setForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
+ const [editing, setEditing] = useState(null);
+ const [error, setError] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
+
+ const resetForm = () => { setForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); setEditing(null); setError(null); };
+
+ const handleSave = async () => {
+ setError(null);
+ if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) {
+ setError('All fields are required'); return;
+ }
+ const payload = {
+ seatClassId: form.seatClassId,
+ maxWeightKg: parseInt(form.maxWeightKg),
+ maxPiecesCount: parseInt(form.maxPiecesCount),
+ excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100),
+ };
+ try {
+ if (editing) {
+ await update.mutateAsync({ id: editing.id, ...payload });
+ } else {
+ await create.mutateAsync(payload);
+ }
+ resetForm();
+ onClose();
+ } catch (e: any) {
+ setError(e?.response?.data?.message ?? 'Failed to save');
+ }
+ };
+
+ return (
+ <>
+ {isLoading ? (
+
+ ) : (
+ {a.seatClass?.name ?? a.seatClassId} },
+ { key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs },
+ { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => {(a.excessFeePerKg / 100).toFixed(2)} ETB },
+ ]}
+ actions={[
+ {
+ label: 'Edit', icon: Edit, variant: 'secondary' as const,
+ onClick: (a: BaggageAllowance) => {
+ setEditing(a);
+ setForm({ seatClassId: a.seatClassId, maxWeightKg: String(a.maxWeightKg), maxPiecesCount: String(a.maxPiecesCount), excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2) });
+ setError(null);
+ },
+ },
+ { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
+ ]}
+ loading={false}
+ emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.'
+ />
+ )}
+
+ { resetForm(); onClose(); }}
+ title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
+ size="md"
+ >
+
+ {error &&
{error}
}
+
+
Seat Class *
+
setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
+ Select seat class...
+ {allClasses.map(sc => {sc.name} )}
+
+ {editing &&
Seat class cannot be changed. Delete and recreate to change.
}
+
+
+
+
Excess Fee per kg (ETB) *
+
setForm({ ...form, excessFeePerKg: e.target.value })} />
+
Amount charged per kg above the free allowance
+
+
+
{ resetForm(); onClose(); }}>Cancel
+
+ {editing ? 'Update' : 'Save'}
+
+
+
+
+
+ setDeleteConfirm({ isOpen: false, id: null })}
+ onConfirm={() => remove.mutate(deleteConfirm.id!)}
+ title="Delete Allowance Rule"
+ message="Are you sure you want to delete this baggage allowance rule?"
+ confirmText="Delete"
+ isDanger
+ isLoading={remove.isPending}
+ warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
+ />
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx
new file mode 100644
index 000000000..abdb0887f
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, Edit, Trash2 } from 'lucide-react';
+import DataTable from '@/components/ui/DataTable';
+import Badge from '@/components/ui/Badge';
+import ActionButton from '@/components/ui/ActionButton';
+import ConfirmDialog from '@/components/ui/ConfirmDialog';
+import Modal from '@/components/ui/Modal';
+import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
+import type { Route, RouteFareRule, SeatClass } from './types';
+
+interface Props {
+ routes: Route[];
+}
+
+type OverrideForm = {
+ isOpen: boolean;
+ rule: RouteFareRule | null; // null = add mode
+ error: string | null;
+};
+
+export default function OverridesTab({ routes }: Props) {
+ const [selectedRouteId, setSelectedRouteId] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<{
+ isOpen: boolean;
+ id: string | null;
+ name: string;
+ cascade: boolean;
+ cascadeChecked: boolean;
+ error?: string;
+ }>({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false });
+ const [form, setForm] = useState({ isOpen: false, rule: null, error: null });
+
+ const { overrides, isLoading } = useRouteFareRules(selectedRouteId);
+ const { update, remove, create } = useRouteFareRuleMutations(selectedRouteId);
+ const { allClasses } = useSeatClasses();
+
+ const handleDeleteClick = (r: RouteFareRule) => {
+ setDeleteConfirm({
+ isOpen: true, id: r.id,
+ name: r.seatClass?.name ?? r.seatClassId,
+ cascade: false, cascadeChecked: false, error: undefined,
+ });
+ };
+
+ const handleConfirmDelete = async () => {
+ try {
+ await remove.mutateAsync(deleteConfirm.id!);
+ setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false });
+ } catch (err: any) {
+ const msg = err?.response?.data?.message ?? err?.message ?? 'Delete failed';
+ const isFkError = msg.includes('Cannot delete') || err?.response?.status === 400;
+ setDeleteConfirm(prev => ({
+ ...prev,
+ cascade: isFkError && !prev.cascade ? true : prev.cascade,
+ cascadeChecked: false,
+ error: msg,
+ }));
+ }
+ };
+
+ const handleFormSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setForm(prev => ({ ...prev, error: null }));
+ const fd = new FormData(e.currentTarget);
+ const baseFareMinor = Math.round(Number(fd.get('baseFareMinor')) * 100) || 0;
+ const surchargeMinor = Math.round(Number(fd.get('surchargeMinor') ?? '0') * 100) || 0;
+ try {
+ if (form.rule) {
+ await update.mutateAsync({ id: form.rule.id, baseFareMinor, surchargeMinor });
+ } else {
+ const routeId = fd.get('routeId') as string;
+ const seatClassId = fd.get('seatClassId') as string;
+ await create.mutateAsync({
+ routeId,
+ seatClassId,
+ passengerCategory: 'ADULT',
+ baseFareMinor,
+ surchargeMinor,
+ validFrom: new Date().toISOString(),
+ });
+ }
+ setForm({ isOpen: false, rule: null, error: null });
+ } catch (err: any) {
+ setForm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Failed to save' }));
+ }
+ };
+
+ const columns = [
+ {
+ key: 'route', label: 'Route',
+ render: (r: RouteFareRule) => {r.route?.name ?? r.routeId} ,
+ },
+ {
+ key: 'seatClass', label: 'Seat Class',
+ render: (r: RouteFareRule) => {r.seatClass?.name ?? r.seatClassId} ,
+ },
+ {
+ key: 'passengerCategory', label: 'Category',
+ render: (r: RouteFareRule) => (
+
+ {r.passengerCategory}
+
+ ),
+ },
+ {
+ key: 'baseFareMinor', label: 'Rate per km',
+ render: (r: RouteFareRule) => {r.baseFareMinor / 100} ,
+ },
+ {
+ key: 'surchargeMinor', label: 'Insurance Fee',
+ render: (r: RouteFareRule) => (
+
+ {r.surchargeMinor ? (r.surchargeMinor / 100).toFixed(2) : '0.00'} ETB
+
+ ),
+ },
+ {
+ key: 'validFrom', label: 'Valid From',
+ render: (r: RouteFareRule) => {new Date(r.validFrom).toLocaleDateString()} ,
+ },
+ {
+ key: 'validUntil', label: 'Valid Until',
+ render: (r: RouteFareRule) => (
+ {r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'}
+ ),
+ },
+ ];
+
+ const isAddMode = form.isOpen && !form.rule;
+ const isPending = create.isPending || update.isPending;
+
+ return (
+ <>
+
+
setSelectedRouteId(e.target.value || null)}
+ >
+ All routes
+ {routes.map(r => (
+ {r.name}{r.code ? ` (${r.code})` : ''}
+ ))}
+
+
+
setForm({ isOpen: true, rule: null, error: null })}
+ >
+ Add Override
+
+
+
+ {!selectedRouteId ? (
+
+ Select a route above to view its fare overrides.
+
+ ) : (
+ setForm({ isOpen: true, rule: r, error: null }),
+ },
+ { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
+ ]}
+ loading={isLoading}
+ emptyMessage="No overrides for this route. Click 'Add Override' to create one."
+ />
+ )}
+
+ {/* Add / Edit modal */}
+ setForm({ isOpen: false, rule: null, error: null })}
+ title={isAddMode ? 'Add Route Override' : 'Edit Route Override'}
+ size="md"
+ >
+
+
+
+ setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false })}
+ onConfirm={handleConfirmDelete}
+ title="Delete Route Override"
+ message={`Delete the fare override for "${deleteConfirm.name}"? The global seat class rate will apply instead.`}
+ confirmText="Delete"
+ isDanger
+ isLoading={remove.isPending}
+ error={deleteConfirm.error}
+ warning={!deleteConfirm.cascade
+ ? 'Removing this override means all future bookings on this route will fall back to the global tariff rate.'
+ : undefined}
+ cascadeWarning={deleteConfirm.cascade
+ ? 'This override has related records that will also be permanently deleted.'
+ : undefined}
+ cascadeChecked={deleteConfirm.cascadeChecked}
+ onCascadeChange={checked => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
+ />
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx
new file mode 100644
index 000000000..aaa676001
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx
@@ -0,0 +1,250 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Modal from '@/components/ui/Modal';
+import ActionButton from '@/components/ui/ActionButton';
+import { BED_POSITIONS, COACH_TYPE_LABELS, getTariffRef } from './constants';
+import type { SeatClass, CoachType, Route } from './types';
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ editingClass: SeatClass | null;
+ allClasses: SeatClass[];
+ coachTypes: CoachType[];
+ routes?: Route[];
+ preselectedRouteId?: string | null;
+ allowRouteOverride?: boolean;
+ onSubmitGlobal: (payload: any) => Promise;
+ onSubmitOverride: (routeId: string, payload: any) => Promise;
+ isPending: boolean;
+}
+
+export default function RateModal({
+ isOpen, onClose, editingClass, allClasses, coachTypes, routes,
+ preselectedRouteId, allowRouteOverride, onSubmitGlobal, onSubmitOverride, isPending,
+}: Props) {
+ const [nationalityType, setNationalityType] = useState('LOCAL');
+ const [coachTypeId, setCoachTypeId] = useState('');
+ const [bedPosition, setBedPosition] = useState('');
+ const [routeId, setRouteId] = useState(preselectedRouteId ?? '');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setNationalityType(editingClass?.nationalityType ?? 'LOCAL');
+ setCoachTypeId(editingClass?.coachTypeId ?? '');
+ setBedPosition(editingClass?.bedPosition ?? '');
+ setRouteId(preselectedRouteId ?? '');
+ setError(null);
+ }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const selectedCoachType = coachTypes.find(c => c.id === coachTypeId) ?? (editingClass as any)?.coachType;
+ const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') ||
+ selectedCoachType?.code?.toLowerCase().includes('bed');
+
+ const suggestName = () => {
+ if (!selectedCoachType) return '';
+ const label = COACH_TYPE_LABELS[selectedCoachType.code] ?? selectedCoachType.name;
+ const pos = bedPosition ? ` ${bedPosition.charAt(0) + bedPosition.slice(1).toLowerCase()}` : '';
+ const nat = nationalityType === 'LOCAL' ? 'Local' : 'Intl';
+ return `${label}${pos} (${nat})`;
+ };
+
+ const suggestRate = () => {
+ if (!selectedCoachType) return '';
+ const ref = getTariffRef(nationalityType, selectedCoachType.code, bedPosition || null);
+ return ref ? String(ref) : '';
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ const fd = new FormData(e.currentTarget);
+
+ try {
+ if (routeId && !editingClass) {
+ const matched = allClasses.find(c =>
+ c.coachTypeId === coachTypeId &&
+ c.nationalityType === nationalityType &&
+ (c.bedPosition ?? null) === (bedPosition || null),
+ );
+ if (!matched) {
+ setError('No matching seat class found for the selected combination. Create the global rate first.');
+ return;
+ }
+ await onSubmitOverride(routeId, {
+ seatClassId: matched.id,
+ passengerCategory: 'ADULT',
+ baseFareMinor: Math.round(Number(fd.get('baseFareMinor')) * 100) || 0,
+ surchargeMinor: Math.round(Number(fd.get('surchargeMinor') ?? '0') * 100) || 0,
+ validFrom: new Date().toISOString(),
+ });
+ } else {
+ await onSubmitGlobal({
+ coachTypeId,
+ name: fd.get('name') as string,
+ nationalityType,
+ bedPosition: bedPosition || null,
+ basePrice: Math.round(Number(fd.get('baseFareMinor')) * 100) || 0,
+ insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') ?? '0') * 100) || 0,
+ isActive: fd.get('isActive') === 'true',
+ });
+ }
+ onClose();
+ } catch (err: any) {
+ setError(err?.response?.data?.message ?? err?.message ?? 'Failed to save');
+ }
+ };
+
+ const isOverrideMode = !!routeId && !editingClass;
+ const title = editingClass ? 'Edit Tariff Rate' : isOverrideMode ? 'Add Route Override' : 'Add Tariff Rate';
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx
new file mode 100644
index 000000000..63c9b5d38
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Edit, Trash2, Search } from 'lucide-react';
+import DataTable from '@/components/ui/DataTable';
+import Badge from '@/components/ui/Badge';
+import ConfirmDialog from '@/components/ui/ConfirmDialog';
+import { getTariffRef } from './constants';
+import type { SeatClass, CoachType } from './types';
+
+interface Props {
+ classes: SeatClass[];
+ coachTypes: CoachType[];
+ isLoading: boolean;
+ onEdit: (cls: SeatClass) => void;
+ onDelete: (id: string, cascade: boolean) => void;
+ isDeleting: boolean;
+ deleteError?: string;
+ deleteSuccess?: number;
+}
+
+export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) {
+ const [search, setSearch] = useState('');
+ const [deleteConfirm, setDeleteConfirm] = useState<{
+ isOpen: boolean;
+ item: SeatClass | null;
+ cascade: boolean;
+ cascadeChecked: boolean;
+ error?: string;
+ }>({ isOpen: false, item: null, cascade: false, cascadeChecked: false });
+
+ // Close dialog on successful delete
+ useEffect(() => {
+ if (!deleteSuccess) return;
+ setDeleteConfirm({ isOpen: false, item: null, cascade: false, cascadeChecked: false });
+ }, [deleteSuccess]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Sync external error into dialog when it arrives
+ useEffect(() => {
+ if (!deleteError || !deleteConfirm.isOpen) return;
+ setDeleteConfirm(prev => ({
+ ...prev,
+ cascade: prev.cascade || deleteError.includes('Cannot delete'),
+ cascadeChecked: false,
+ error: deleteError,
+ }));
+ }, [deleteError]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleDeleteClick = (cls: SeatClass) => {
+ setDeleteConfirm({ isOpen: true, item: cls, cascade: false, cascadeChecked: false, error: undefined });
+ };
+
+ const handleConfirm = () => {
+ onDelete(deleteConfirm.item!.id, deleteConfirm.cascade && deleteConfirm.cascadeChecked);
+ };
+
+ const displayed = classes
+ .filter(c => c.nationalityType)
+ .filter(c => {
+ if (!search) return true;
+ const s = search.toLowerCase();
+ const ct = coachTypes.find(t => t.id === c.coachTypeId);
+ return (
+ c.name?.toLowerCase().includes(s) ||
+ c.nationalityType?.toLowerCase().includes(s) ||
+ c.bedPosition?.toLowerCase().includes(s) ||
+ ct?.name?.toLowerCase().includes(s)
+ );
+ })
+ .sort((a, b) => (a.nationalityType === b.nationalityType ? 0 : a.nationalityType === 'LOCAL' ? -1 : 1));
+
+ const columns = [
+ {
+ key: 'nationalityType', label: 'Passenger Type',
+ render: (c: SeatClass) => (
+
+ {c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
+
+ ),
+ },
+ {
+ key: 'coachType', label: 'Coach Type',
+ render: (c: SeatClass) => {
+ const ct = coachTypes.find(t => t.id === c.coachTypeId);
+ return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId} ;
+ },
+ },
+ {
+ key: 'name', label: 'Class Name',
+ render: (c: SeatClass) => {c.name} ,
+ },
+ {
+ key: 'baseFareMinor', label: 'Rate per km',
+ render: (c: SeatClass) => {
+ const ct = coachTypes.find(t => t.id === c.coachTypeId);
+ const ref = ct ? getTariffRef(c.nationalityType!, ct.code, c.bedPosition ?? null) : undefined;
+ const tariffMinor = ref ? Math.round(ref * 100) : undefined;
+ const matches = tariffMinor === c.baseFareMinor;
+ return (
+
+ {c.baseFareMinor! / 100}
+ {tariffMinor !== undefined && (
+
+ {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
+
+ )}
+
+ );
+ },
+ },
+ {
+ key: 'insuranceFeeMinor', label: 'Insurance Fee',
+ render: (c: SeatClass) => (
+ {c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB
+ ),
+ },
+ {
+ key: 'isActive', label: 'Status',
+ render: (c: SeatClass) => (
+
+ {c.isActive ? 'Active' : 'Inactive'}
+
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+
+ setDeleteConfirm({ isOpen: false, item: null, cascade: false, cascadeChecked: false })}
+ onConfirm={handleConfirm}
+ title="Delete Tariff Rate"
+ message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
+ confirmText="Delete"
+ isDanger
+ isLoading={isDeleting}
+ error={deleteConfirm.error}
+ warning={!deleteConfirm.cascade
+ ? 'This seat class may be referenced by fare rules, route overrides, and segment fares. Deleting it will impact pricing across all routes.'
+ : undefined}
+ cascadeWarning={deleteConfirm.cascade
+ ? 'This seat class has related fare rules, route overrides, or segment fares that will also be permanently deleted.'
+ : undefined}
+ cascadeChecked={deleteConfirm.cascadeChecked}
+ onCascadeChange={checked => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
+ />
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts
new file mode 100644
index 000000000..f34a80b69
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts
@@ -0,0 +1,31 @@
+export const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
+
+export const COACH_TYPE_LABELS: Record = {
+ HSC: 'Regular Seat (Hard Seat)',
+ HBC: 'Economy Bed (Hard Berth)',
+ SBC: 'VIP Bed (Soft Berth)',
+};
+
+export const TARIFF_REFERENCE: Record> = {
+ LOCAL: {
+ 'HSC-null': 0.03,
+ 'HBC-UPPER': 0.04,
+ 'HBC-MIDDLE': 0.055,
+ 'HBC-LOWER': 0.06,
+ 'SBC-UPPER': 0.075,
+ 'SBC-LOWER': 0.08,
+ },
+ INTERNATIONAL: {
+ 'HSC-null': 0.06,
+ 'HBC-UPPER': 0.08,
+ 'HBC-MIDDLE': 0.11,
+ 'HBC-LOWER': 0.12,
+ 'SBC-UPPER': 0.15,
+ 'SBC-LOWER': 0.16,
+ },
+};
+
+export function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) {
+ const key = `${coachCode}-${bedPosition ?? 'null'}`;
+ return TARIFF_REFERENCE[nationalityType]?.[key];
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
new file mode 100644
index 000000000..cd6f6b5b4
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
@@ -0,0 +1,106 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { apiClient } from '@/lib/api-client';
+import type { SeatClass, CoachType, Route, RouteFareRule, BaggageAllowance } from './types';
+
+function toArray(data: unknown): T[] {
+ if (Array.isArray(data)) return data as T[];
+ const d = data as any;
+ return d?.items ?? d?.data ?? [];
+}
+
+export function useSeatClasses() {
+ const { data, isLoading } = useQuery({
+ queryKey: ['seat-classes'],
+ queryFn: () => apiClient.get('/seat-classes'),
+ });
+ return { allClasses: toArray(data), isLoading };
+}
+
+export function useCoachTypes() {
+ const { data } = useQuery({
+ queryKey: ['coach-types'],
+ queryFn: () => apiClient.get('/fleet/coach-types'),
+ });
+ return { coachTypes: toArray(data) };
+}
+
+export function useRoutes() {
+ const { data } = useQuery({
+ queryKey: ['routes-active'],
+ queryFn: () => apiClient.get('/routes?activeOnly=true'),
+ });
+ return { routes: toArray(data) };
+}
+
+export function useRouteFareRules(routeId: string | null) {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['route-fare-rules', routeId],
+ queryFn: () => apiClient.get(`/schedules/routes/${routeId}/fare-rules`),
+ enabled: !!routeId,
+ });
+ return { overrides: toArray(data), isLoading, refetch };
+}
+
+export function useSeatClassMutations(onSuccess: () => void) {
+ const queryClient = useQueryClient();
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
+
+ const create = useMutation({
+ mutationFn: (data: any) => apiClient.post('/seat-classes', data),
+ onSuccess: () => { invalidate(); onSuccess(); },
+ });
+ const update = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data),
+ onSuccess: () => { invalidate(); onSuccess(); },
+ });
+ const remove = useMutation({
+ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) =>
+ apiClient.delete(`/seat-classes/${id}${cascade ? '?cascade=true' : ''}`),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove };
+}
+
+export function useRouteFareRuleMutations(routeId: string | null) {
+ const queryClient = useQueryClient();
+ const invalidate = (rid?: string) =>
+ queryClient.invalidateQueries({ queryKey: ['route-fare-rules', rid ?? routeId] });
+
+ const create = useMutation({
+ mutationFn: ({ routeId: rid, ...data }: any) => apiClient.post(`/schedules/routes/${rid}/fare-rules`, data),
+ onSuccess: (_: any, vars: any) => invalidate(vars.routeId),
+ });
+ const update = useMutation({
+ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/routes/fare-rules/${id}`, data),
+ onSuccess: invalidate,
+ });
+ const remove = useMutation({
+ mutationFn: (id: string) => apiClient.delete(`/schedules/routes/fare-rules/${id}`),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove };
+}
+
+export function useBaggageMutations() {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['baggage-allowances'],
+ queryFn: () => apiClient.get('/agents/excess-baggage/allowances'),
+ });
+
+ const create = useMutation({
+ mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
+ onSuccess: () => refetch(),
+ });
+ const update = useMutation({
+ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
+ onSuccess: () => refetch(),
+ });
+ const remove = useMutation({
+ mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
+ onSuccess: () => refetch(),
+ });
+
+ return { allowances: toArray(data), isLoading, create, update, remove };
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index 530b335e7..aef643b06 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -1,285 +1,64 @@
'use client';
import { useState } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, Edit, Trash2, Search } from 'lucide-react';
-import DataTable from '@/components/ui/DataTable';
-import Badge from '@/components/ui/Badge';
+import { Plus } from 'lucide-react';
import ActionButton from '@/components/ui/ActionButton';
-import Modal from '@/components/ui/Modal';
-import ConfirmDialog from '@/components/ui/ConfirmDialog';
-import { apiClient } from '@/lib/api-client';
-
-interface SeatClass { id: string; name: string; }
-
-const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
-const COACH_TYPE_LABELS: Record = {
- HSC: 'Regular Seat (Hard Seat)',
- HBC: 'Economy Bed (Hard Berth)',
- SBC: 'VIP Bed (Soft Berth)',
-};
-
-const TARIFF_REFERENCE: Record> = {
- LOCAL: {
- 'HSC-null': 0.03,
- 'HBC-UPPER': 0.04,
- 'HBC-MIDDLE': 0.055,
- 'HBC-LOWER': 0.06,
- 'SBC-UPPER': 0.075,
- 'SBC-LOWER': 0.08,
- },
- INTERNATIONAL: {
- 'HSC-null': 0.06,
- 'HBC-UPPER': 0.08,
- 'HBC-MIDDLE': 0.11,
- 'HBC-LOWER': 0.12,
- 'SBC-UPPER': 0.15,
- 'SBC-LOWER': 0.16,
- },
-};
-
-function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) {
- const key = `${coachCode}-${bedPosition ?? 'null'}`;
- return TARIFF_REFERENCE[nationalityType]?.[key];
-}
+import TariffTab from './TariffTab';
+import OverridesTab from './OverridesTab';
+import BaggageTab from './BaggageTab';
+import RateModal from './RateModal';
+import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
+import type { SeatClass, TabType } from './types';
export default function TariffRatesPage() {
- const [tab, setTab] = useState<'tariff' | 'baggage'>('tariff');
- const [search, setSearch] = useState('');
- const [showModal, setShowModal] = useState(false);
- const [editingClass, setEditingClass] = useState(null);
- const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
- const [formError, setFormError] = useState(null);
- const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
- const [selectedBedPosition, setSelectedBedPosition] = useState('');
- const [selectedNationalityType, setSelectedNationalityType] = useState('LOCAL');
+ const [tab, setTab] = useState('tariff');
+ const [showRateModal, setShowRateModal] = useState(false);
+ const [editingClass, setEditingClass] = useState(null);
+ const [showBaggageModal, setShowBaggageModal] = useState(false);
+ const [preselectedRouteId, setPreselectedRouteId] = useState(null);
+ const [deleteError, setDeleteError] = useState(undefined);
+ const [deleteSuccess, setDeleteSuccess] = useState(0);
- const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
- const [editingAllowance, setEditingAllowance] = useState(null);
- const [baggageError, setBaggageError] = useState(null);
- const [baggageModal, setBaggageModal] = useState(false);
- const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
+ const { allClasses, isLoading } = useSeatClasses();
+ const { coachTypes } = useCoachTypes();
+ const { routes } = useRoutes();
- const queryClient = useQueryClient();
+ const closeRateModal = () => { setShowRateModal(false); setEditingClass(null); setPreselectedRouteId(null); };
- const { data: allowances, isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
- queryKey: ['baggage-allowances'],
- queryFn: () => apiClient.get('/agents/excess-baggage/allowances'),
- enabled: tab === 'baggage',
- });
+ const seatClassMutations = useSeatClassMutations(closeRateModal);
+ const overrideMutations = useRouteFareRuleMutations(preselectedRouteId);
- const createAllowanceMutation = useMutation({
- mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data),
- onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); },
- onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'),
- });
-
- const updateAllowanceMutation = useMutation({
- mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data),
- onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); },
- onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'),
- });
-
- const deleteAllowanceMutation = useMutation({
- mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`),
- onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); },
- onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'),
- });
-
- const handleSaveAllowance = async () => {
- setBaggageError(null);
- if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) {
- setBaggageError('All fields are required'); return;
- }
- const payload = {
- seatClassId: baggageForm.seatClassId,
- maxWeightKg: parseInt(baggageForm.maxWeightKg),
- maxPiecesCount: parseInt(baggageForm.maxPiecesCount),
- excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100),
- };
- if (editingAllowance) {
- await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload });
- } else {
- await createAllowanceMutation.mutateAsync(payload);
- }
- };
-
- const { data: classesData, isLoading } = useQuery({
- queryKey: ['seat-classes'],
- queryFn: () => apiClient.get('/seat-classes'),
- });
-
- const { data: coachTypesData } = useQuery({
- queryKey: ['coach-types'],
- queryFn: () => apiClient.get('/fleet/coach-types'),
- });
-
- const createMutation = useMutation({
- mutationFn: (data: any) => apiClient.post('/seat-classes', data),
- onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
- onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'),
- });
-
- const updateMutation = useMutation({
- mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data),
- onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
- onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'),
- });
-
- const deleteMutation = useMutation({
- mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`),
- onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); },
- onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })),
- });
-
- const closeModal = () => {
- setShowModal(false);
- setEditingClass(null);
- setFormError(null);
- setSelectedCoachTypeId('');
- setSelectedBedPosition('');
- setSelectedNationalityType('LOCAL');
- };
-
- const openEdit = (cls: any) => {
- setEditingClass(cls);
- setSelectedCoachTypeId(cls.coachTypeId || '');
- setSelectedBedPosition(cls.bedPosition || '');
- setSelectedNationalityType(cls.nationalityType || 'LOCAL');
- setFormError(null);
- setShowModal(true);
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setFormError(null);
- const fd = new FormData(e.currentTarget);
- const payload: any = {
- coachTypeId: selectedCoachTypeId,
- name: fd.get('name') as string,
- nationalityType: selectedNationalityType,
- bedPosition: selectedBedPosition || null,
- basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
- insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
- isActive: fd.get('isActive') === 'true',
- };
+ const handleSubmitGlobal = async (payload: any) => {
if (editingClass) {
- await updateMutation.mutateAsync({ id: editingClass.id, data: payload });
+ await seatClassMutations.update.mutateAsync({ id: editingClass.id, data: payload });
} else {
- await createMutation.mutateAsync(payload);
+ await seatClassMutations.create.mutateAsync(payload);
+ }
+ closeRateModal();
+ };
+
+ const handleSubmitOverride = async (routeId: string, payload: any) => {
+ await overrideMutations.create.mutateAsync({ routeId, ...payload });
+ closeRateModal();
+ };
+
+ const handleDelete = async (id: string, cascade: boolean) => {
+ setDeleteError(undefined);
+ try {
+ await seatClassMutations.remove.mutateAsync({ id, cascade });
+ setDeleteSuccess(n => n + 1);
+ } catch (err: any) {
+ const msg = err?.response?.data?.message ?? err?.message ?? 'Delete failed';
+ setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg);
}
};
- const coachTypesArray: any[] = Array.isArray(coachTypesData)
- ? coachTypesData
- : (coachTypesData as any)?.data || (coachTypesData as any)?.items || [];
-
- const allClasses: any[] = Array.isArray(classesData)
- ? classesData
- : (classesData as any)?.items || (classesData as any)?.data || [];
-
- const allowancesArray: any[] = Array.isArray(allowances) ? allowances : (allowances as any)?.items || [];
-
- const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
-
- const displayed = tariffClasses.filter((c: any) => {
- if (!search) return true;
- const s = search.toLowerCase();
- return (
- c.name?.toLowerCase().includes(s) ||
- c.nationalityType?.toLowerCase().includes(s) ||
- c.bedPosition?.toLowerCase().includes(s) ||
- c.coachType?.name?.toLowerCase().includes(s)
- );
- }).sort((a: any, b: any) => {
- if (a.nationalityType === b.nationalityType) return 0;
- return a.nationalityType === 'LOCAL' ? -1 : 1;
- });
-
- const suggestName = () => {
- const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
- if (!ct) return '';
- const label = COACH_TYPE_LABELS[ct.code] || ct.name;
- const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : '';
- const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl';
- return `${label}${pos} (${nat})`;
- };
-
- // Returns the human-readable rate (e.g. 0.03); stored value = this × 100
- const suggestRate = () => {
- const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
- if (!ct) return '';
- const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
- return ref ? String(ref) : '';
- };
-
- const columns = [
- {
- key: 'nationalityType', label: 'Passenger Type',
- render: (c: any) => (
-
- {c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
-
- ),
- },
- {
- key: 'coachType', label: 'Coach Type',
- render: (c: any) => {
- const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
- return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId} ;
- },
- },
- {
- key: 'name', label: 'Class Name',
- render: (c: any) => {c.name} ,
- },
- {
- key: 'baseFareMinor', label: 'Rate per km',
- render: (c: any) => {
- const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
- const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
- const tariffMinor = ref ? Math.round(ref * 100) : undefined;
- const matches = tariffMinor === c.baseFareMinor;
- return (
-
- {c.baseFareMinor / 100}
- {tariffMinor !== undefined && (
-
- {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
-
- )}
-
- );
- },
- },
- {
- key: 'insuranceFeeMinor', label: 'Insurance Fee',
- render: (c: any) => (
- {c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB
- ),
- },
- {
- key: 'isActive', label: 'Status',
- render: (c: any) => (
-
- {c.isActive ? 'Active' : 'Inactive'}
-
- ),
- },
+ const tabs: { key: TabType; label: string }[] = [
+ { key: 'tariff', label: 'Seat Class Tariffs' },
+ { key: 'overrides', label: 'Route Overrides' },
+ { key: 'baggage', label: 'Excess Luggage Rates' },
];
- const actions = [
- { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit },
- {
- label: 'Delete', icon: Trash2, variant: 'danger' as const,
- onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }),
- },
- ];
-
- const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId)
- ?? editingClass?.coachType;
- const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed');
-
return (
@@ -289,332 +68,72 @@ export default function TariffRatesPage() {
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
-
{
+ {tab !== 'overrides' && (
+ {
if (tab === 'baggage') {
- setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
- setEditingAllowance(null);
- setBaggageError(null);
- setBaggageModal(true);
+ setShowBaggageModal(true);
} else {
setEditingClass(null);
- setFormError(null);
- setShowModal(true);
+ setPreselectedRouteId(null);
+ setShowRateModal(true);
}
- }}
- >
- {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
-
+ }}>
+ {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
+
+ )}
- setTab('tariff')}
- className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'tariff' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
- >
- Seat Class Tariffs
-
- setTab('baggage')}
- className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
- >
- Excess Luggage Rates
-
+ {tabs.map(t => (
+ setTab(t.key)}
+ className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
+ >
+ {t.label}
+
+ ))}
{tab === 'tariff' && (
- <>
-
-
- setSearch(e.target.value)}
- />
-
-
- >
+
{ setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
+ onDelete={handleDelete}
+ isDeleting={seatClassMutations.remove.isPending}
+ deleteError={deleteError}
+ deleteSuccess={deleteSuccess}
+ />
+ )}
+
+ {tab === 'overrides' && (
+
)}
{tab === 'baggage' && (
- allowancesLoading ? (
-
- ) : allowancesArray.length === 0 ? (
-
- No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
-
- ) : (
- {a.seatClass?.name ?? a.seatClassId} },
- { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs },
- { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} ETB },
- ]}
- actions={[
- {
- label: 'Edit', icon: Edit, variant: 'secondary' as const,
- onClick: (a: any) => {
- setEditingAllowance(a);
- setBaggageForm({
- seatClassId: a.seatClassId,
- maxWeightKg: String(a.maxWeightKg),
- maxPiecesCount: String(a.maxPiecesCount),
- excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2),
- });
- setBaggageError(null);
- setBaggageModal(true);
- },
- },
- {
- label: 'Delete', icon: Trash2, variant: 'danger' as const,
- onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }),
- },
- ]}
- loading={false}
- emptyMessage="No allowance rules found."
- />
- )
+ setShowBaggageModal(false)}
+ />
)}
- setDeleteConfirm({ isOpen: false, item: null })}
- onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)}
- title="Delete Tariff Rate"
- message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
- confirmText="Delete"
- isDanger
- isLoading={deleteMutation.isPending}
- error={deleteConfirm.error}
- warning="Bookings in progress may be affected. Ensure a replacement rate exists."
+
-
- setDeleteAllowanceConfirm({ isOpen: false, id: null })}
- onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)}
- title="Delete Allowance Rule"
- message="Are you sure you want to delete this baggage allowance rule?"
- confirmText="Delete"
- isDanger
- isLoading={deleteAllowanceMutation.isPending}
- warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created."
- />
-
- { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}
- title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
- size="md"
- >
-
- {baggageError && (
-
{baggageError}
- )}
-
-
Seat Class *
-
setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
- Select seat class...
- {allClasses.map((sc: SeatClass) => {sc.name} )}
-
- {editingAllowance &&
Seat class cannot be changed. Delete and recreate to change.
}
-
-
-
-
Excess Fee per kg (ETB) *
-
setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
-
Amount charged per kg above the free allowance
-
-
-
{ setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel
-
- {editingAllowance ? 'Update' : 'Save'}
-
-
-
-
-
-
-
-
);
}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts
new file mode 100644
index 000000000..b69dab6df
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts
@@ -0,0 +1,47 @@
+export interface SeatClass {
+ id: string;
+ name: string;
+ coachTypeId?: string;
+ nationalityType?: string;
+ bedPosition?: string | null;
+ baseFareMinor?: number;
+ insuranceFeeMinor?: number;
+ isActive?: boolean;
+}
+
+export interface CoachType {
+ id: string;
+ code: string;
+ name: string;
+}
+
+export interface Route {
+ id: string;
+ name: string;
+ code?: string;
+}
+
+export interface RouteFareRule {
+ id: string;
+ routeId: string;
+ seatClassId: string;
+ passengerCategory: string;
+ baseFareMinor: number;
+ surchargeMinor?: number | null;
+ validFrom: string;
+ validUntil?: string | null;
+ createdAt: string;
+ seatClass?: SeatClass;
+ route?: Route;
+}
+
+export interface BaggageAllowance {
+ id: string;
+ seatClassId: string;
+ maxWeightKg: number;
+ maxPiecesCount: number;
+ excessFeePerKg: number;
+ seatClass?: SeatClass;
+}
+
+export type TabType = 'tariff' | 'overrides' | 'baggage';
From e785c117e4e050ecf206453e8f9cc51983caa12c Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 16:08:33 +0300
Subject: [PATCH 07/67] Update arrival to terminal text
---
.../portal/src/app/booking/confirmation/page.tsx | 2 +-
.../portal/src/app/booking/seats/page.tsx | 8 --------
apps/edr-passenger-web/portal/src/app/guide/page.tsx | 4 ++--
apps/edr-passenger-web/portal/src/lib/generate-voucher.ts | 2 +-
4 files changed, 4 insertions(+), 12 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index f4e67656d..cd316be92 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -727,7 +727,7 @@ export default function ConfirmationPage() {
- ✅ Please arrive at the station at least 30 minutes before
+ ✅ Please arrive at the station at least 2 hours before
departure.
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 1463b238a..9ffb3adcb 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
@@ -1927,9 +1927,6 @@ export default function SeatsPage() {
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
- const seatFare = assignedSeat
- ? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
- : isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
@@ -1979,11 +1976,6 @@ export default function SeatsPage() {
>
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
- {assignedSeat && seatFare != null && (
-
- ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
-
- )}
);
diff --git a/apps/edr-passenger-web/portal/src/app/guide/page.tsx b/apps/edr-passenger-web/portal/src/app/guide/page.tsx
index 3297d1389..09f965a96 100644
--- a/apps/edr-passenger-web/portal/src/app/guide/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/guide/page.tsx
@@ -174,7 +174,7 @@ export default function HowToGuidePage() {
- 📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
+ 📱 Show the QR code at the gate for easy check-in. Arrive at least 2 hours before departure.
@@ -225,7 +225,7 @@ export default function HowToGuidePage() {
What should I bring on the day of travel?
- Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
+ Bring your ticket (digital or printed), valid ID/passport, and arrive 2 hours before departure.
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
index 47ee3fdcc..dab5bba26 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -374,7 +374,7 @@ function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: numb
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
- doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
+ doc.text('Please arrive at least 2 hours before scheduled departure.', margin + padX, y + 15);
return y + cardH + 4;
}
From b5a97d344a6fbb98f5d84603765a48b17d590cc3 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:10:00 +0000
Subject: [PATCH 08/67] train
---
apps/edr-freight-api/src/app.module.ts | 139 +++++++------
.../src/common/booking-guards.ts | 4 +
...0000-LinkWagonMovementToTransferRequest.ts | 54 ++++++
.../train-scheduling/booking-batch.service.ts | 24 ++-
.../train-scheduling.service.ts | 56 ++++--
.../trains/dto/update-train-yard.dto.ts | 12 ++
.../trains/train-builder.controller.ts | 11 ++
.../modules/trains/train-builder.service.ts | 73 +++++--
.../wagons/entities/wagon-movement.entity.ts | 9 +
.../wagon-transfer-requests.controller.ts | 25 +++
.../wagons/wagon-transfer-requests.service.ts | 50 ++++-
.../src/modules/wagons/wagons.module.ts | 11 +-
.../src/modules/wagons/wagons.service.ts | 2 +
.../src/seed/freight-permissions.registry.ts | 4 +
.../contracts/GlCreateBookingForm.tsx | 33 ++++
.../trainBuilder/ChangeYardModal.tsx | 103 ++++++++++
.../trainBuilder/TrainConsistStrip.tsx | 159 ---------------
.../TrainCompositionDiagram.tsx | 54 ++++--
.../wagons/WagonTransferRequestsModal.tsx | 183 +++++++++++++++++-
.../backoffice/src/lib/permissions.ts | 3 +
.../contracts/ContractClearanceListPage.tsx | 91 ++++++++-
.../trainBuilder/TrainBuilderDetailPage.tsx | 73 ++++---
.../trainBuilder/TrainBuilderListPage.tsx | 2 +-
.../BatchScheduleDetailPage.tsx | 6 +
.../TrainScheduleV2DetailPage.tsx | 13 +-
.../backoffice/src/services/api.ts | 25 +++
.../src/services/trainBuilder.service.ts | 14 +-
.../backoffice/src/services/wagon.service.ts | 17 ++
.../backoffice/src/types/trainScheduling.ts | 14 ++
.../components/UpcomingWindowsSection.tsx | 26 ++-
.../BookingDetailPage/ReadonlyBookingView.tsx | 17 +-
.../ContractBookingWindowsSection.tsx | 31 ++-
.../contracts/NewShipmentRequestPage.tsx | 100 ++++++++--
.../src/pages/contracts/booking-window.ts | 10 +
.../portal/src/services/bookings.service.ts | 6 +
packages/types/src/freight/index.ts | 2 +
36 files changed, 1101 insertions(+), 355 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
delete mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index afb214137..6c31c4f08 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -53,22 +53,23 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
-import { DemoUsersSeeder } from "./seed/demo-users.seeder";
-import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
+// Disabled seeds — imports commented out with their provider/injection/run below.
+// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
+// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
-import { PricingDataSeeder } from "./seed/pricing-data.seeder";
+// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
-import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
-import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
-import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
-import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
-import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
-import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
-import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
-import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
+// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
+// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
+// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
+// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
+// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
+// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
+// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
+// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
-import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
-import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
+// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
+// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -192,21 +193,22 @@ import { LoggerMiddleware } from "./logger.middleware";
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
- DemoUsersSeeder,
- FreightStaffUsersSeeder,
- PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
- DemoFreightDataSeeder,
- GovCompaniesSeeder,
- IndodeFacilitySeeder,
- Batch14TestDataSeeder,
- Batch5TestDataSeeder,
- Batch7TestDataSeeder,
- Batch8TestDataSeeder,
- WarehouseDemoSeeder,
- ExportDjiboutiInterchangeDemoSeeder,
- MarshallingDemoTrainsSeeder,
+ // Disabled seeds — providers commented out (imports/injection/run too):
+ // DemoUsersSeeder,
+ // FreightStaffUsersSeeder,
+ // PricingDataSeeder,
+ // DemoFreightDataSeeder,
+ // GovCompaniesSeeder,
+ // IndodeFacilitySeeder,
+ // Batch14TestDataSeeder,
+ // Batch5TestDataSeeder,
+ // Batch7TestDataSeeder,
+ // Batch8TestDataSeeder,
+ // WarehouseDemoSeeder,
+ // ExportDjiboutiInterchangeDemoSeeder,
+ // MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
@@ -216,51 +218,66 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
- private readonly demoUsersSeeder: DemoUsersSeeder,
- private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
- private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
- private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
- private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
- private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
- private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
- private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
- private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
- private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
- private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
- private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
- private readonly govCompaniesSeeder: GovCompaniesSeeder,
+ // Disabled seeds — injections commented out (imports/provider/run too):
+ // private readonly demoUsersSeeder: DemoUsersSeeder,
+ // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
+ // private readonly pricingDataSeeder: PricingDataSeeder,
+ // private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
+ // private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
+ // private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
+ // private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
+ // private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
+ // private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
+ // private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
+ // private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
+ // private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
+ // private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
+ // ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
+ // Everything else below is intentionally disabled. Seeders stay registered
+ // as providers and injected; only their .run() calls are commented out, so
+ // re-enabling any of them is a one-line uncomment.
+
+ // Permissions foundation — keep enabled:
+ // freightPermissionKeyMigration → renames legacy permission keys
+ // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
+ // edrOrgSeeder → seeds org/unit + the Permission catalog
+ // freightPositionsSeeder → seeds Position + PositionPermission rows
+ // (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
- await this.demoUsersSeeder.run();
- await this.freightStaffUsersSeeder.run();
- await this.pricingDataSeeder.run();
+
+ // File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
- await this.indodeFacilitySeeder.run();
- await this.batch14TestDataSeeder.run();
- await this.batch5TestDataSeeder.run();
- await this.batch7TestDataSeeder.run();
- await this.batch8TestDataSeeder.run();
- await this.warehouseDemoSeeder.run();
- await this.exportDjiboutiInterchangeDemoSeeder.run();
- await this.marshallingDemoTrainsSeeder.run();
- // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
- // Each block self-guards on an empty-table check, so this is safe every boot.
- // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
- // FileUploadSettingsSeeder) are intentionally disabled — they stay
- // registered as providers but are not run. Re-inject + call .run() to enable.
- // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
- // rules are disabled inside the seeder). Kept running for the staff users.
- await this.demoFreightDataSeeder.run();
- // Government entities (with importer/exporter profiles) that government
- // bookings bill to. Idempotent — keyed by fixed IDs.
- await this.govCompaniesSeeder.run();
+
+ // Dropdown settings are not seeded on boot; run them with
+ // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
+
+ // ── Disabled: demo / test / reference data seeds ──
+ // Uncomment a line to re-enable that seed.
+ // await this.demoUsersSeeder.run();
+ // await this.freightStaffUsersSeeder.run();
+ // await this.pricingDataSeeder.run();
+ // await this.indodeFacilitySeeder.run();
+ // await this.batch14TestDataSeeder.run();
+ // await this.batch5TestDataSeeder.run();
+ // await this.batch7TestDataSeeder.run();
+ // await this.batch8TestDataSeeder.run();
+ // await this.warehouseDemoSeeder.run();
+ // await this.exportDjiboutiInterchangeDemoSeeder.run();
+ // await this.marshallingDemoTrainsSeeder.run();
+ // demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
+ // rules are already disabled inside the seeder).
+ // await this.demoFreightDataSeeder.run();
+ // Government entities (importer/exporter profiles) that government bookings
+ // bill to. Idempotent — keyed by fixed IDs.
+ // await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index d3344aa5a..9769a7f18 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
export const WagonTransferFulfill = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
+/** Admin: read every staffer's wagon-transfer history (not just one's own). */
+export const WagonTransferHistoryAll = () =>
+ BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
+
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
diff --git a/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts
new file mode 100644
index 000000000..3f10fa874
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts
@@ -0,0 +1,54 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Link each physical wagon move back to the transfer request that drove it, so
+ * the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
+ * Nullable — legacy moves and non-request manual corrections carry no request.
+ * Also indexes `moved_by_user_id` for the per-user history queries.
+ */
+export class LinkWagonMovementToTransferRequest2180000000000
+ implements MigrationInterface
+{
+ name = 'LinkWagonMovementToTransferRequest2180000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_movements
+ ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
+ `);
+ await queryRunner.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
+ ) THEN
+ ALTER TABLE freight.wagon_movements
+ ADD CONSTRAINT fk_wm_transfer_request
+ FOREIGN KEY (transfer_request_id)
+ REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
+ END IF;
+ END $$;
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
+ ON freight.wagon_movements (transfer_request_id)
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_wm_moved_by
+ ON freight.wagon_movements (moved_by_user_id)
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_movements
+ DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.wagon_movements
+ DROP COLUMN IF EXISTS transfer_request_id
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index a9711c1e5..e51d4ad23 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train: {
+ id: string;
+ code: string;
+ trainName: string | null;
+ } | null;
locomotive: {
code: string;
name: string | null;
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
- trainSet: { locomotive: true },
+ trainSet: { locomotive: true, train: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below;
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
+ train: s.trainSet?.train
+ ? {
+ id: s.trainSet.train.id,
+ code: s.trainSet.train.code,
+ trainName: s.trainSet.train.trainName ?? null,
+ }
+ : null,
locomotive: loco
? {
code: loco.code,
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
+ train: s.trainSet?.train
+ ? {
+ id: s.trainSet.train.id,
+ code: s.trainSet.train.code,
+ trainName: s.trainSet.train.trainName ?? null,
+ }
+ : null,
locomotive: loco
? {
code: loco.code,
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 874516548..7e0720238 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
@@ -282,6 +282,8 @@ interface BookingWindowRow {
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
+ /** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
+ route_stations: string[] | null;
}
@Injectable()
@@ -1329,9 +1331,15 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
- if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
+ // The locomotives pull GROSS weight: the customers' cargo plus the empty
+ // weight of every planned wagon — cargo-only comparison understates the load.
+ const planTareTons = roundTons(
+ wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
+ );
+ const grossWeightTons = roundTons(totalWeightTons + planTareTons);
+ if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
- `Train set locomotives cannot pull ${totalWeightTons}T`,
+ `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
);
}
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
@@ -4605,14 +4613,8 @@ export class TrainSchedulingService {
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
- maxGrossTons: roundTons(
- wagons.reduce(
- (sum, w) =>
- sum +
- (Number(w.wagonType?.tareWeightTons) || 0) +
- (Number(w.wagonType?.capacityTons) || 0),
- 0,
- ),
+ totalTareTons: roundTons(
+ wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
),
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
@@ -4725,7 +4727,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
- dy.label AS destination_label, dy.code AS destination_code
+ dy.label AS destination_label, dy.code AS destination_code,
+ (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
+ FROM freight.route_milestones rm
+ JOIN freight.yards rmy ON rmy.id = rm.yard_id
+ WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.contract_routes cr
ON cr.deleted_at IS NULL
@@ -4777,7 +4783,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
- dy.label AS destination_label, dy.code AS destination_code
+ dy.label AS destination_label, dy.code AS destination_code,
+ (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
+ FROM freight.route_milestones rm
+ JOIN freight.yards rmy ON rmy.id = rm.yard_id
+ WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.contract_id = $1
@@ -4823,7 +4833,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
- dy.label AS destination_label, dy.code AS destination_code
+ dy.label AS destination_label, dy.code AS destination_code,
+ (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
+ FROM freight.route_milestones rm
+ JOIN freight.yards rmy ON rmy.id = rm.yard_id
+ WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -4845,6 +4859,17 @@ export class TrainSchedulingService {
}
private mapBookingWindowRow(r: BookingWindowRow) {
+ const origin = r.origin_label ?? r.origin_code ?? null;
+ const destination = r.destination_label ?? r.destination_code ?? null;
+ // Full corridor from the route's milestones (origin → stops → destination).
+ // Falls back to the schedule's origin/destination when no milestones exist.
+ const milestoneStops = (r.route_stations ?? []).filter(
+ (s): s is string => Boolean(s),
+ );
+ const routeStations =
+ milestoneStops.length >= 2
+ ? milestoneStops
+ : [origin, destination].filter((s): s is string => Boolean(s));
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
@@ -4860,8 +4885,9 @@ export class TrainSchedulingService {
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
- origin: r.origin_label ?? r.origin_code ?? null,
- destination: r.destination_label ?? r.destination_code ?? null,
+ origin,
+ destination,
+ routeStations,
};
}
diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts
new file mode 100644
index 000000000..428651288
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts
@@ -0,0 +1,12 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsUUID } from 'class-validator';
+
+export class UpdateTrainYardDto {
+ @ApiProperty({
+ format: 'uuid',
+ description:
+ 'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.',
+ })
+ @IsUUID()
+ currentYardId!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
index 7281aca31..7ed3e4c42 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
@@ -7,6 +7,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
+ Patch,
Post,
Put,
Query,
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
+import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@@ -57,6 +59,15 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
+ @Patch(':id/yard')
+ @FleetManage()
+ @ApiOperation({
+ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
+ })
+ setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
+ return this.trainBuilderService.setYard(id, dto.currentYardId);
+ }
+
@Post(':id/wagons')
@FleetManage()
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
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 c28873234..b92ab21d6 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
@@ -1,4 +1,4 @@
-import { Freight, WagonStatus } from '@edr/types';
+import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
+import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -202,7 +203,6 @@ export class TrainBuilderService {
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
- const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
@@ -221,14 +221,17 @@ export class TrainBuilderService {
totals: {
wagonCount: wagons.length,
totalTareTons,
+ // Informational only — building never checks against full capacity;
+ // the real gross check (cargo + tare vs haul limit) runs at allocation.
totalCapacityTons,
- maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
- // Fully loaded gross vs. what the weakest locomotive can haul.
- weightUtilizationPct: maxPullWeightTons
- ? round((maxGrossTons / maxPullWeightTons) * 100)
+ // Cargo the locomotives can still haul once pulling the empty consist.
+ payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
+ // Share of the haul limit consumed by the empty wagons alone.
+ tareUtilizationPct: maxPullWeightTons
+ ? round((totalTareTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
@@ -269,6 +272,54 @@ export class TrainBuilderService {
return this.getComposition(id);
}
+ /**
+ * Relocate the train to another yard. The consist moves as one unit: every
+ * coupled locomotive and wagon follows to the new yard (so their current
+ * yards always match the train's), and each wagon gets a movement-ledger row.
+ * Blocked while the train is out on a dispatched run.
+ */
+ async setYard(id: string, currentYardId: string) {
+ await this.dataSource.transaction(async (manager) => {
+ const train = await this.getEditableTrain(manager, id);
+ if (train.currentYardId === currentYardId) return;
+ const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
+ if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
+
+ await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
+
+ const links = await manager
+ .getRepository(TrainLocomotive)
+ .find({ where: { trainId: train.id } });
+ if (links.length) {
+ await manager
+ .getRepository(Locomotive)
+ .update(
+ { id: In(links.map((link) => link.locomotiveId)) },
+ { currentYardId: yard.id },
+ );
+ }
+
+ const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
+ const now = new Date();
+ for (const wagon of wagons) {
+ if (wagon.currentYardId === yard.id) continue;
+ await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
+ // Ledger row keeps the wagon's yard history auditable (mirrors the
+ // manual-relocation path in the wagons service).
+ await manager.getRepository(WagonMovement).save(
+ manager.getRepository(WagonMovement).create({
+ wagonId: wagon.id,
+ fromYardId: wagon.currentYardId ?? null,
+ toYardId: yard.id,
+ kind: WagonMovementKind.Manual,
+ occurredAt: now,
+ }),
+ );
+ }
+ });
+ return this.getComposition(id);
+ }
+
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -361,12 +412,8 @@ export class TrainBuilderService {
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
- const maxGrossTons = round(
- wagons.reduce(
- (sum, w) =>
- sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
- 0,
- ),
+ const totalTareTons = round(
+ wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
);
return {
id: train.id,
@@ -379,7 +426,7 @@ export class TrainBuilderService {
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
- maxGrossTons,
+ totalTareTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts
index 7c5ed092c..a36e5deea 100644
--- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts
+++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from './wagon.entity';
+import { WagonTransferRequest } from './wagon-transfer-request.entity';
/**
* Ledger of every physical wagon relocation between yards — one row per move.
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
movedByUserId?: string | null;
+ /** The transfer request this move fulfilled, when it came from one. */
+ @Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
+ transferRequestId?: string | null;
+
+ @ManyToOne(() => WagonTransferRequest, { nullable: true })
+ @JoinColumn({ name: 'transfer_request_id' })
+ transferRequest?: WagonTransferRequest | null;
+
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
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 07557f431..12fdaf27c 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
@@ -16,6 +16,7 @@ import {
FleetManage,
FleetView,
WagonTransferFulfill,
+ WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status);
}
+ // 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).
+ @Get('history')
+ @ApiOperation({
+ summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
+ })
+ myHistory(@CurrentUser() user: TCurrentUser) {
+ // Never fall through to the all-staff view: getHistory(undefined) means
+ // "everyone", so a missing caller id must return empty, not leak scope.
+ if (!user?.id) return { requests: [], movements: [] };
+ return this.service.getHistory(user.id);
+ }
+
+ @Get('history/all')
+ @WagonTransferHistoryAll()
+ @ApiQuery({ name: 'userId', required: false })
+ @ApiOperation({
+ summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
+ })
+ allHistory(@Query('userId') userId?: string) {
+ return this.service.getHistory(userId);
+ }
+
@Get(':id')
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
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 64b408b6a..bf69d767d 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
@@ -6,14 +6,24 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { In, Repository } from 'typeorm';
+import { In, IsNull, Not, Repository } from 'typeorm';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { Wagon } from './entities/wagon.entity';
+import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';
+/** Bundled per-user activity: requests they touched + wagons they moved. */
+export interface TransferHistory {
+ requests: WagonTransferRequest[];
+ movements: WagonMovement[];
+}
+
+/** How many ledger rows the history returns at most (newest first). */
+const HISTORY_LIMIT = 500;
+
const REQUEST_RELATIONS = {
fromYard: true,
toYard: true,
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
private readonly requestRepo: Repository,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository,
+ @InjectRepository(WagonMovement)
+ private readonly movementRepo: Repository,
private readonly wagonsService: WagonsService,
) {}
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
);
}
- // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
+ // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
+ // each stamped with this request's id so history can link them back).
await this.wagonsService.bulkTransfer(
{ wagonIds, toYardId: request.toYardId },
userId,
+ { transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
return this.findById(id);
}
+ /**
+ * Per-user transfer history: the requests a user filed OR fulfilled, plus the
+ * individual wagons they physically moved (linked back to their request when
+ * one drove the move). Pass a `userId` to scope to one staffer; pass
+ * `undefined` for the admin all-staff view. Scope is decided by the CALLER
+ * (the controller passes the caller's id unless they hold the history-all
+ * permission) — this method trusts its argument.
+ */
+ async getHistory(userId?: string | null): Promise {
+ const requests = await this.requestRepo.find({
+ where: userId
+ ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
+ : {},
+ relations: REQUEST_RELATIONS,
+ order: { createdAt: 'DESC' },
+ take: HISTORY_LIMIT,
+ });
+
+ const movements = await this.movementRepo.find({
+ // Own view: moves I made. All view: every user-attributed move (skip the
+ // system-written loaded/reposition legs that carry no mover).
+ where: userId
+ ? { movedByUserId: userId }
+ : { movedByUserId: Not(IsNull()) },
+ relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
+ order: { occurredAt: 'DESC' },
+ take: HISTORY_LIMIT,
+ });
+
+ return { requests, movements };
+ }
+
/** Withdraw a still-PENDING request. */
async cancelRequest(id: string): Promise {
const request = await this.findById(id);
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts
index 4bb1afd33..107a31e7e 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
+import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@Module({
- imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
+ imports: [
+ TypeOrmModule.forFeature([
+ Wagon,
+ WagonMovement,
+ WagonTransferRequest,
+ Train,
+ Yard,
+ ]),
+ ],
controllers: [
WagonsController,
TrainWagonsReorderController,
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
index ad39fbf7a..55dc177c9 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
@@ -180,6 +180,7 @@ export class WagonsService {
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
+ opts?: { transferRequestId?: string | null },
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
@@ -215,6 +216,7 @@ export class WagonsService {
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
+ transferRequestId: opts?.transferRequestId ?? null,
occurredAt: new Date(),
}),
);
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 7c3fe0700..6e4ec81b1 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
+ perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
+ // Admin: read every staffer's transfer history. Without it, a user only sees
+ // their own (the /history endpoint uses the caller id, backend-enforced).
+ transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
},
trains: {
view: 'edr_freight_app:trains:view',
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index 223b357c1..0cd00d63b 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
+import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
useContractDetail,
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
}>();
const [searchParams] = useSearchParams();
const requestIdParam = searchParams.get("requestId");
+ // Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
+ // contract (GL only picks a new schedule). Set by the clearance Rebook action.
+ const copyFromParam = searchParams.get("copyFrom");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
+ // The expired booking a Rebook is copying from (its cargo seeds the form).
+ const { data: copyFromBooking } = useQuery({
+ queryKey: ["rebook-copy-from", copyFromParam],
+ queryFn: () => bookingsService.getById(copyFromParam!),
+ enabled: Boolean(copyFromParam),
+ });
+
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
+ // Rebook seed: copy the source booking's container lines once. (Bulk weight /
+ // item count isn't on the booking payload yet, so bulk rebooks fall through to
+ // the normal contract seed and GL re-enters the quantity.)
+ useEffect(() => {
+ if (!copyFromBooking || prefilled) return;
+ const lines = copyFromBooking.bookingContainers ?? [];
+ if (!lines.length) return;
+ setPrefilled(true);
+ setContainerLines(
+ lines.map((c) => {
+ const qty = Math.max(1, c.quantity);
+ return {
+ containerSize: String(c.containerType?.sizeFt ?? ""),
+ quantity: String(qty),
+ hazardousQuantity: "0",
+ reeferQuantity: "0",
+ units: Array.from({ length: qty }, emptyUnit),
+ };
+ }),
+ );
+ }, [copyFromBooking, prefilled]);
+
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
useEffect(() => {
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
new file mode 100644
index 000000000..0e4bddbc3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
@@ -0,0 +1,103 @@
+import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { MapPin } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { api } from "@/services/api";
+import type { TrainComposition } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+/**
+ * Relocate the train to another yard. The consist moves as one unit — every
+ * coupled locomotive and wagon follows, so their current yards always match
+ * the train's.
+ */
+export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
+ const { toast } = useToast();
+ const [yardId, setYardId] = useState("");
+
+ const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
+ const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
+
+ useEffect(() => {
+ if (opened) setYardId(composition?.currentYard?.id ?? "");
+ }, [opened, composition]);
+
+ const handleSave = async () => {
+ if (!composition || !yardId) return;
+ try {
+ await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
+ toast({ title: "Train relocated" });
+ onClose();
+ } catch (err) {
+ toast({
+ title: "Relocation failed",
+ description: parseError(err, "Could not change the yard"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const memberCount =
+ (composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
+
+ return (
+ Change yard — train {composition?.code}}
+ radius="lg"
+ centered
+ >
+
+ }>
+ The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
+ locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
+ {composition?.totals.wagonCount ?? 0} wagon
+ {(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
+ are relocated so their current yard always matches the train's. Wagon moves are
+ recorded in the movement ledger.
+
+ ({
+ value: y.id,
+ label: y.label ?? y.code,
+ }))}
+ value={yardId || null}
+ onChange={(v) => setYardId(v ?? "")}
+ searchable
+ />
+
+
+ Cancel
+
+
+ Relocate train
+
+
+
+
+ );
+}
+
+export interface ChangeYardModalProps {
+ composition: TrainComposition | null;
+ opened: boolean;
+ onClose: () => void;
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
deleted file mode 100644
index c86dbc8ef..000000000
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
-import { Train as TrainIcon } from "lucide-react";
-
-import type {
- TrainCompositionLocomotive,
- TrainCompositionWagon,
-} from "@/services/trainBuilder.service";
-
-/**
- * Visual consist: locomotives + wagons drawn in order on a rail, the way the
- * train would leave the yard. Scrolls horizontally for long consists.
- */
-export default function TrainConsistStrip({
- locomotives,
- wagons,
- emptyHint = "No wagons attached yet — add wagons from the yard below.",
-}: TrainConsistStripProps) {
- return (
-
-
-
- {locomotives.map((loco, index) => (
-
- {index > 0 ? : null}
-
-
- ))}
- {wagons.map((wagon) => (
-
-
-
-
- ))}
-
- {/* The rail */}
-
- {!wagons.length ? (
-
- {emptyHint}
-
- ) : null}
-
-
- );
-}
-
-export interface TrainConsistStripProps {
- locomotives: TrainCompositionLocomotive[];
- wagons: TrainCompositionWagon[];
- emptyHint?: string;
-}
-
-function Coupler() {
- return (
-
- );
-}
-
-function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
- return (
-
-
-
-
-
- {locomotive.code}
-
-
-
- {locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
-
-
-
- );
-}
-
-function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
- return (
-
-
-
- #{wagon.sequenceNumber ?? "—"}
-
-
- {wagon.wagonNumber}
-
-
- {wagon.wagonType?.code ?? "—"}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
index 6ba1a0e29..2805ba60a 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
@@ -506,12 +506,19 @@ function TrackBed() {
export function TrainCompositionDiagram({
locomotive,
+ locomotives,
wagons,
freightType,
trainNumber,
totalLengthMeters,
}: {
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
+ /** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
+ locomotives?: Array<{
+ code?: string | null;
+ name?: string | null;
+ maxPullWeightTons?: number | null;
+ }> | null;
wagons: DiagramWagonInput[];
freightType?: string | null;
trainNumber?: string | null;
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
}) {
const { ref, width } = useElementSize();
+ const locos = useMemo(
+ () => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
+ [locomotives, locomotive],
+ );
+
const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType],
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
+ // Weakest locomotive caps the set — same rule the allocation engine applies.
+ const pullLimits = locos
+ .map((l) => Number(l.maxPullWeightTons))
+ .filter((v) => Number.isFinite(v) && v > 0);
+ const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
return {
total: normalized.length,
assigned,
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity,
- pullUtil:
- locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
- ? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
- : null,
+ pullLimit,
+ pullUtil: pullLimit
+ ? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
+ : null,
};
- }, [normalized, locomotive]);
+ }, [normalized, locos]);
- // cars-per-row from measured width; locomotive counts as one car
+ // cars-per-row from measured width; each locomotive counts as one car
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
const cars = useMemo(
- () => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
- [normalized],
+ () => [
+ ...locos.map((l) => ({ kind: "loco" as const, l })),
+ ...normalized.map((w) => ({ kind: "wagon" as const, w })),
+ ],
+ [locos, normalized],
);
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
- if (!locomotive && !wagons.length) return null;
+ if (!locos.length && !wagons.length) return null;
return (
Locomotive load ·{" "}
{stats.totalTare > 0
- ? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
- : `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
+ ? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
+ : `${stats.totalWeight}T of ${stats.pullLimit}T`}
95 ? "red.7" : "edr-green.7"}>
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
{carIndex > 0 ? : null}
{car.kind === "loco" ? (
- locomotive ? (
-
- ) : null
+
) : (
)}
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 01e65deb2..f17c74d9a 100644
--- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
@@ -10,6 +10,8 @@ import {
Modal,
ScrollArea,
Stack,
+ Switch,
+ Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
+ History,
Inbox,
PackageCheck,
Warehouse,
@@ -25,8 +28,13 @@ import {
import { useMemo, useState } from "react";
import { api } from "@/services/api";
+import { useAuth } from "@/auth/useAuth";
+import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
-import type { WagonTransferRequest } from "@/services/wagon.service";
+import type {
+ WagonMovementRecord,
+ WagonTransferRequest,
+} from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
);
+const STATUS_COLOR: Record = {
+ PENDING: "gray",
+ FULFILLED: "teal",
+ CANCELLED: "red",
+};
+
+const fmtDateTime = (iso: string) =>
+ new Date(iso).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+
+/**
+ * Per-user transfer history. A staffer sees their OWN activity — the requests
+ * they filed or fulfilled, and the individual wagons they moved. Holders of
+ * `transfer_history_all` get an "All staff" toggle that widens the view; the
+ * backend enforces the scope regardless of the toggle.
+ */
+function HistoryPanel({ opened }: { opened: boolean }) {
+ const { user } = useAuth();
+ const canSeeAll = hasPermission(
+ user,
+ FREIGHT_PERMS.wagons.transferHistoryAll,
+ );
+ const myId = (user as { id?: string } | null | undefined)?.id;
+ const [allStaff, setAllStaff] = useState(false);
+ const scopeAll = canSeeAll && allStaff;
+
+ const mine = useQuery({
+ ...api.wagonTransferRequests.history.queryOptions(),
+ enabled: opened && !scopeAll,
+ });
+ const all = useQuery({
+ ...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
+ enabled: opened && scopeAll,
+ });
+ const source = scopeAll ? all : mine;
+ const requests = source.data?.requests ?? [];
+ const movements: WagonMovementRecord[] = source.data?.movements ?? [];
+
+ const roleBadge = (r: WagonTransferRequest) => {
+ if (myId && r.fulfilledByUserId === myId)
+ return (
+
+ fulfilled
+
+ );
+ if (myId && r.requestedByUserId === myId)
+ return (
+
+ requested
+
+ );
+ return null;
+ };
+
+ return (
+
+ {canSeeAll ? (
+
+ setAllStaff(e.currentTarget.checked)}
+ label="All staff"
+ color="edr-green"
+ />
+
+ ) : null}
+
+ {source.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ Requests{scopeAll ? "" : " you touched"}
+
+ {requests.length === 0 ? (
+
+ No requests yet.
+
+ ) : (
+
+ {requests.map((r) => (
+
+
+
+
+ {roleBadge(r)}
+
+ {r.status.toLowerCase()}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Wagons moved
+
+ {movements.length === 0 ? (
+
+ No wagon moves yet.
+
+ ) : (
+
+
+ {movements.map((m) => (
+
+
+
+
+ {m.wagon?.wagonNumber ?? "Wagon"}
+
+
+ {yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
+
+ {m.transferRequestId ? (
+
+ from request
+
+ ) : null}
+
+
+ {fmtDateTime(m.occurredAt)}
+
+
+
+ ))}
+
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
+ * A second tab shows per-user transfer history.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
+ const [tab, setTab] = useState("queue");
const [active, setActive] = useState(null);
const [picked, setPicked] = useState>(new Set());
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
}
>
+
+
+ }>
+ Queue
+
+ }>
+ History
+
+
+
+
{!active ? (
// ---- Pending queue ----
isLoading ? (
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
)}
+
+
+
+
+
+
);
};
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index 92fe85ad7..9bf9aab6e 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
+ transferRequest: "edr_freight_app:wagons:transfer_request",
+ transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
+ transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
},
trains: {
view: "edr_freight_app:trains:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
index 142173298..91d944ffc 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -75,6 +82,8 @@ interface ClearanceRow {
freightType: string;
originLabel: string;
destinationLabel: string;
+ /** Full ordered corridor across the contract's route legs (origin → … → destination). */
+ routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
@@ -93,6 +102,25 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
+/**
+ * Chain the contract's ordered route legs into one corridor of stops —
+ * origin of the first leg, then each leg's destination (Djibouti → Adama →
+ * Dire Dawa). A leg whose origin differs from the previous destination inserts
+ * that stop too, so gapped route lists stay readable.
+ */
+function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
+ const stops: string[] = [];
+ for (const r of routes) {
+ const origin = yardLabel(r.originYard);
+ const destination = yardLabel(r.destinationYard);
+ if (stops.length === 0 || stops[stops.length - 1] !== origin) {
+ stops.push(origin);
+ }
+ stops.push(destination);
+ }
+ return stops;
+}
+
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
+ routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
const r = row.original;
return (
-
-
- {r.originLabel}
-
-
-
- {r.destinationLabel}
-
+
+ {(r.routeStops.length >= 2
+ ? r.routeStops
+ : [r.originLabel, r.destinationLabel]
+ ).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
+ onRebook={(row) =>
+ navigate(
+ `/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
+ )
+ }
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
canCreateBooking,
onOpen,
onCreateBooking,
+ onRebook,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
+ onRebook: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
+ // A customs shipment whose booking lost its slot — GL rebooks it (customer
+ // can't self-rebook a customs booking). Copies the expired booking's cargo.
+ const isRebookable = (r: ShipmentBookingRow) =>
+ canCreateBooking &&
+ Boolean(r.contractId) &&
+ r.customs &&
+ r.status === "EXPIRED";
+
const columns = useMemo[]>(
() => [
{
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
+ const rebookable = isRebookable(r);
return (
) : null}
+ {rebookable ? (
+ }
+ onClick={() => onRebook(r)}
+ >
+ Rebook
+
+ ) : null}
) : null}
+ {rebookable ? (
+ }
+ onClick={() => onRebook(r)}
+ >
+ Rebook (GL)
+
+ ) : null}
{r.contractId ? (
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
index 7be2775bb..063787fa9 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
+ MapPin,
MoreHorizontal,
Replace,
Ruler,
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
+import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
-import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
+import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
+ const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
>
Change locomotives
+ }
+ disabled={!composition.editable}
+ onClick={() => setYardModalOpen(true)}
+ >
+ Change yard
+
}
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
- label: "Max gross / haul limit",
- value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
+ label: "Payload available",
+ value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
) : null}
-
-
-
- Consist
-
- {composition.locomotives.length} locomotive
- {composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
- {totals.wagonCount === 1 ? "" : "s"}
-
-
-
+
+ ({
+ code: loco.code,
+ name: loco.name,
+ maxPullWeightTons: loco.maxPullWeightTons,
+ }))}
+ wagons={composition.wagons.map((wagon, index) => ({
+ sequenceNo: wagon.sequenceNumber ?? index + 1,
+ capacityTons: wagon.wagonType?.capacityTons ?? 0,
+ // No bookings at build time — wagons ride empty until allocation.
+ assignedWeightTons: 0,
+ tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
+ wagonTypeCode: wagon.wagonType?.code ?? null,
+ physicalWagonNumber: wagon.wagonNumber,
+ allocations: [],
+ }))}
+ trainNumber={composition.code}
+ totalLengthMeters={totals.totalLengthMeters}
+ />
+
-
-
-
+
+
+ The real weight check happens at allocation: booked cargo weight plus
+ wagon tare (gross) must stay within the locomotives' haul limit.
+
+
-
-
+
+
{composition.editable ? (
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
onClose={() => setLocoModalOpen(false)}
/>
+ setYardModalOpen(false)}
+ />
+
setDisbandOpen(false)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
index 9799bf6e4..53bd1a818 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
- {row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
+ {row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
{row.original.totalLengthMeters}m
),
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
index ae75debb1..0f80e423a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
+ {data.train ? (
+ }>
+ Train {data.train.code}
+ {data.train.trainName ? ` — ${data.train.trainName}` : ""}
+
+ ) : null}
{data.locomotive ? (
}>
Loco {data.locomotive.code} ·{" "}
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 f4cd3be02..c9c4a042a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
1 ? "Locomotives" : "Locomotive",
value: locomotives.length
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index af6263e58..d0e14d4a5 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -200,6 +200,7 @@ import {
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
+ type TransferHistory,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -1701,6 +1702,21 @@ export const api = {
undefined,
() => [["wagonTransferRequests"]],
),
+
+ history: endpoint(
+ "wagonTransferRequests",
+ "history",
+ () => wagonTransferRequestService.myHistory().then((r) => r.data),
+ () => ["wagonTransferRequests", "history", "mine"],
+ ),
+
+ historyAll: endpoint<{ userId?: string }, TransferHistory>(
+ "wagonTransferRequests",
+ "historyAll",
+ ({ userId }) =>
+ wagonTransferRequestService.allHistory(userId).then((r) => r.data),
+ ({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
+ ),
},
trains: {
@@ -1781,6 +1797,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
+ setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
+ "train-builder",
+ "setYard",
+ ({ id, currentYardId }) =>
+ trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index f94bd6ff7..e6c462992 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
- maxGrossTons: number;
+ totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
}
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
+ /** Informational only — building never checks against full capacity. */
totalCapacityTons: number;
- maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
- weightUtilizationPct: number | null;
+ /** Cargo the locomotives can still haul once pulling the empty consist. */
+ payloadCapacityTons: number;
+ /** Share of the haul limit consumed by the empty wagons alone. */
+ tareUtilizationPct: number | null;
lengthUtilizationPct: number | null;
}
@@ -126,7 +129,7 @@ export interface AvailableTrain {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
- maxGrossTons: number;
+ totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
atOriginYard: boolean;
@@ -157,6 +160,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }),
+ /** Relocate the train — coupled locomotives and wagons move with it. */
+ setYard: (id: string, currentYardId: string) =>
+ apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
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 6464b94f5..e818467f5 100644
--- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
+ /** The transfer request this move fulfilled, when one drove it. */
+ transferRequestId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
+ wagon?: { id: string; wagonNumber?: string } | null;
}
export const wagonService = {
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
note?: string;
}
+/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
+export interface TransferHistory {
+ requests: WagonTransferRequest[];
+ movements: WagonMovementRecord[];
+}
+
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
),
+ /** The caller's own history (both roles: requests they filed and fulfilled). */
+ myHistory: () =>
+ apiClient.get('/wagon-transfer-requests/history'),
+ /** Admin: any/all staff's history (optional userId filter). */
+ allHistory: (userId?: string) =>
+ apiClient.get(
+ `/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
+ ),
getById: (id: string) =>
apiClient.get(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index 154db034c..ee301b970 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train: {
+ id: string;
+ code: string;
+ trainName: string | null;
+ } | null;
locomotive: {
code: string;
name: string | null;
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train?: {
+ id: string;
+ code: string;
+ trainName?: string | null;
+ } | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
index ad2d5ace9..f9a3dd246 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
@@ -1,5 +1,5 @@
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
-import { memo, useMemo, useState } from "react";
+import { Fragment, memo, useMemo, useState } from "react";
import {
ArrowRight,
CalendarClock,
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
+import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
const INK = "#10202F";
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
}}
>
-
-
- {w.origin ?? "—"}
-
-
-
- {w.destination ?? "—"}
-
+
+ {windowRouteStops(w).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
{w.reference && (
navigate("/support"),
}}
/>
@@ -181,14 +186,18 @@ export function ReadonlyBookingView({
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
- onRebook={onRebook}
+ onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isExpired ? (
) : isPendingConsolidation ? (
-
-
- {w.origin ?? "—"}
-
-
-
- {w.destination ?? "—"}
-
+
+ {windowRouteStops(w).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
index c0bfbfc4c..0b5c1796c 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
- const [quantity, setQuantity] = useState(1);
+ // Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
+ const [qtyBySize, setQtyBySize] = useState>(
+ {},
+ );
+ // Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
+ const [bulkAmount, setBulkAmount] = useState("");
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
+ // Only the container sizes the contract was scoped for (20ft, 40ft, or both).
+ const SIZE_ORDER = ["20ft", "40ft"];
+ const enabledSizes = SIZE_ORDER.filter((s) =>
+ contract.cargoScope?.some(
+ (l) => (l.containerSize ?? "").toLowerCase() === s,
+ ),
+ );
+ // A CONTAINER contract should always carry scope lines; fall back to both.
+ const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
+
+ // Bulk: pick the bulk scope line and read how it's measured.
+ const bulkScope =
+ contract.cargoScope?.find((l) => !l.containerSize) ??
+ contract.cargoScope?.[0];
+ const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
+
const handleSubmit = () => {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
};
if (isContainer) {
- const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
- dto.containers = [
- {
- containerSize: size,
- quantity: Number(quantity) || 1,
- },
- ];
+ // One line per size the user filled; 0 (or blank) sizes are dropped.
+ const containers = sizes
+ .map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
+ .filter((line) => line.quantity > 0);
+ if (containers.length === 0) {
+ toast.error("Enter a quantity for at least one container size");
+ return;
+ }
+ dto.containers = containers;
} else {
+ const amount = Number(bulkAmount) || 0;
+ if (amount <= 0) {
+ toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
+ return;
+ }
dto.bulk = {
- cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
- cargoWeightTons: Number(quantity) || undefined,
+ cargoTypeId: bulkScope?.cargoTypeId ?? null,
+ ...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
};
}
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
/>
)}
-
+ {isContainer ? (
+
+ {sizes.map((size) => (
+
+ setQtyBySize((prev) => ({ ...prev, [size]: v }))
+ }
+ min={0}
+ allowDecimal={false}
+ />
+ ))}
+ {sizes.length > 1 ? (
+
+ Enter a quantity for each size you need — leave a size at 0 if
+ you don't need it.
+
+ ) : null}
+ {hasCustoms ? (
+
+ Global Logistics schedules the shipment date during customs
+ clearance — you only state the quantity.
+
+ ) : null}
+
+ ) : (
+
+ )}
{capacity?.length ? (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
index ab25284ba..1f0825f57 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
+/**
+ * Full ordered corridor for a window — every stop from origin through the
+ * intermediate milestones to the destination. Falls back to origin/destination
+ * when the backend sends no milestone chain (older schedules, routeless windows).
+ */
+export function windowRouteStops(w: MyBookingWindow): string[] {
+ if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
+ return [w.origin ?? "—", w.destination ?? "—"];
+}
+
/**
* The next upcoming (not-yet-open) window the customer should come back for —
* the one that OPENS soonest from now. Two guards matter here:
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 1128b0883..af2c8e4a5 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -94,6 +94,12 @@ export interface MyBookingWindow {
departureDate: string;
origin: string | null;
destination: string | null;
+ /**
+ * Full ordered corridor for the window's route — origin, every intermediate
+ * milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
+ * Falls back to [origin, destination] when the route has no milestones.
+ */
+ routeStations: string[];
}
export interface GeneratePriceResponse {
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 9addde7dc..39e8985ee 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
bookingId?: string | null;
kind: WagonMovementKind;
movedByUserId?: string | null;
+ /** The transfer request this move fulfilled, when it came from one. */
+ transferRequestId?: string | null;
occurredAt: string;
note?: string | null;
}
From ae07b461f095323233d90ae1331f7ca696128fdb Mon Sep 17 00:00:00 2001
From: Yonas Tewabe
Date: Tue, 14 Jul 2026 16:30:23 +0300
Subject: [PATCH 09/67] Update docker-compose.yaml
---
docker-compose.yaml | 2 --
1 file changed, 2 deletions(-)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index c0b58b44c..7637c500a 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -24,8 +24,6 @@ services:
dockerfile: apps/edr-gps-tracker/Dockerfile
secrets:
- npmrc
- depends_on:
- - freight-api
ports:
- "${GT06_TCP_PORT:-5023}:5023"
environment:
From 5cffe2860cd279c9c01d6efed8b28fd8422dc73b Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:49:39 +0000
Subject: [PATCH 10/67] fix train builder and consolidation
---
...70000000000-ScheduleWagonAdjustmentLogs.ts | 50 +++
.../schedule-wagon-adjustment-log.entity.ts | 38 ++
.../dto/adjust-schedule-consist.dto.ts | 26 ++
.../train-scheduling.controller.ts | 29 ++
.../train-scheduling.service.ts | 315 +++++++++++++-
.../modules/trains/train-builder.service.ts | 71 +++-
.../trainScheduling/AdjustConsistModal.tsx | 396 ++++++++++++++++++
.../src/pages/bookings/NewBookingPage.tsx | 32 +-
.../trainBuilder/TrainBuilderDetailPage.tsx | 4 +-
.../BatchScheduleDetailPage.tsx | 19 +
.../TrainScheduleV2DetailPage.tsx | 20 +
.../backoffice/src/services/api.ts | 26 ++
.../src/services/trainBuilder.service.ts | 67 +++
.../new-booking-form/step5-cargo-details.tsx | 16 +-
14 files changed, 1092 insertions(+), 17 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts
create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
diff --git a/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts
new file mode 100644
index 000000000..7fecfccbb
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts
@@ -0,0 +1,50 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Consist adjustments from a schedule: staff can trim free wagons off a built
+ * train when their tare pushes gross weight over the locomotives' pull limit
+ * (incl. overage tolerance), or couple extra yard wagons on while weight and
+ * length headroom remain. Each add/remove is logged here so the schedule keeps
+ * an auditable history; the built train itself is updated in place.
+ *
+ * Plain columns (no FKs) so the history survives wagon/train deletion.
+ *
+ * NOTE: the shared dev DB has no applied migration history, so this is also
+ * hand-applied there. IF NOT EXISTS keeps that idempotent.
+ */
+export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
+ name = 'ScheduleWagonAdjustmentLogs2170000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
+ train_schedule_id uuid NOT NULL,
+ train_id uuid NOT NULL,
+ action varchar(10) NOT NULL,
+ wagon_id uuid NOT NULL,
+ wagon_number varchar(50) NOT NULL,
+ adjusted_by_user_id uuid,
+ occurred_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz,
+ CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
+ );
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
+ ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
+ ON freight.schedule_wagon_adjustment_logs (train_id);
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
new file mode 100644
index 000000000..485d29452
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
@@ -0,0 +1,38 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index } from 'typeorm';
+
+export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
+export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
+
+/**
+ * History row for a consist adjustment made from a schedule: staff coupled a
+ * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
+ * e.g. trimming free wagons whose tare pushed gross weight over the
+ * locomotives' pull limit. Plain columns (no FK relations) so the history
+ * survives the wagon or train being deleted later.
+ */
+@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
+@Index(['trainScheduleId'])
+@Index(['trainId'])
+export class ScheduleWagonAdjustmentLog extends BaseEntity {
+ @Column({ name: 'train_schedule_id', type: 'uuid' })
+ trainScheduleId!: string;
+
+ @Column({ name: 'train_id', type: 'uuid' })
+ trainId!: string;
+
+ @Column({ name: 'action', type: 'varchar', length: 10 })
+ action!: WagonAdjustmentAction;
+
+ @Column({ name: 'wagon_id', type: 'uuid' })
+ wagonId!: string;
+
+ @Column({ name: 'wagon_number', type: 'varchar', length: 50 })
+ wagonNumber!: string;
+
+ @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
+ adjustedByUserId!: string | null;
+
+ @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
+ occurredAt!: Date;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts
new file mode 100644
index 000000000..e031bf9f0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts
@@ -0,0 +1,26 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsArray, IsOptional, IsUUID } from 'class-validator';
+
+export class AdjustScheduleConsistDto {
+ @ApiPropertyOptional({
+ type: [String],
+ format: 'uuid',
+ description:
+ "AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
+ })
+ @IsOptional()
+ @IsArray()
+ @IsUUID('all', { each: true })
+ addWagonIds?: string[];
+
+ @ApiPropertyOptional({
+ type: [String],
+ format: 'uuid',
+ description:
+ 'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
+ })
+ @IsOptional()
+ @IsArray()
+ @IsUUID('all', { each: true })
+ removeWagonIds?: string[];
+}
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 f8cc7e3d1..c6e22589f 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
@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
+import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
);
}
+ @Get("schedules/:id/consist")
+ @TrainSchedulingView()
+ @ApiOperation({
+ summary:
+ "Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
+ })
+ getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
+ return this.trainSchedulingService.getScheduleConsist(id);
+ }
+
+ @Post("schedules/:id/adjust-consist")
+ @TrainSchedulingManage()
+ @ApiOperation({
+ summary:
+ "Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
+ })
+ adjustScheduleConsist(
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: AdjustScheduleConsistDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.trainSchedulingService.adjustScheduleConsist(
+ id,
+ dto,
+ resolveAuthUserId(user),
+ );
+ }
+
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.
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 7e0720238..04583c777 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
@@ -25,6 +25,7 @@ import {
FindOptionsWhere,
ILike,
In,
+ IsNull,
Not,
QueryFailedError,
Raw,
@@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
+import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
@@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
+import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
@@ -1331,11 +1334,25 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
- // The locomotives pull GROSS weight: the customers' cargo plus the empty
- // weight of every planned wagon — cargo-only comparison understates the load.
- const planTareTons = roundTons(
- wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
- );
+ // The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
+ // A built train hauls EVERY coupled wagon's tare — empty ones included —
+ // so train-bound schedules count the full consist, not just planned slots.
+ const consistWagons = schedule.trainSet?.trainId
+ ? await this.dataSource.getRepository(Wagon).find({
+ where: { trainId: schedule.trainSet.trainId },
+ relations: { wagonType: true },
+ })
+ : null;
+ const planTareTons = consistWagons
+ ? roundTons(
+ consistWagons.reduce(
+ (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
+ 0,
+ ),
+ )
+ : roundTons(
+ wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
+ );
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
@@ -4626,6 +4643,294 @@ export class TrainSchedulingService {
});
}
+ /**
+ * Consist snapshot for the adjust-consist UI: the built train's wagons with
+ * loaded/removable flags, gross weight (cargo + FULL consist tare) and length
+ * against the locomotive limits incl. overage tolerance, addable yard wagons,
+ * and the adjustment history.
+ */
+ async getScheduleConsist(scheduleId: string) {
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
+ const builtTrain = schedule.trainSet?.train;
+ if (!builtTrain) {
+ throw new BadRequestException(
+ 'This schedule was not created from a built train — its consist cannot be adjusted here',
+ );
+ }
+
+ const wagons = await this.dataSource.getRepository(Wagon).find({
+ where: { trainId: builtTrain.id },
+ relations: { wagonType: true },
+ order: { sequenceNumber: 'ASC' },
+ });
+ const addableWagons = await this.dataSource.getRepository(Wagon).find({
+ where: {
+ trainId: IsNull(),
+ status: WagonStatus.Available,
+ currentYardId: builtTrain.currentYardId ?? undefined,
+ },
+ relations: { wagonType: true },
+ order: { wagonNumber: 'ASC' },
+ });
+ const adjustments = await this.dataSource
+ .getRepository(ScheduleWagonAdjustmentLog)
+ .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
+
+ // Slots with cargo aboard — their physical wagons are "loaded" and can
+ // never be trimmed.
+ const loadedWagonIds = new Set(
+ (schedule.trainSet?.wagons ?? [])
+ .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
+ .map((slot) => slot.physicalWagonId as string),
+ );
+
+ const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
+ const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
+ const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
+ const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
+ const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
+
+ const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
+ const consistTareTons = roundTons(
+ wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
+ );
+ const consistLengthMeters = roundTons(
+ wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
+ );
+
+ const mapWagon = (wagon: Wagon) => ({
+ id: wagon.id,
+ wagonNumber: wagon.wagonNumber,
+ sequenceNumber: wagon.sequenceNumber,
+ wagonType: wagon.wagonType
+ ? {
+ id: wagon.wagonType.id,
+ code: wagon.wagonType.code,
+ tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
+ capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
+ lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
+ }
+ : null,
+ });
+
+ return {
+ schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
+ train: {
+ id: builtTrain.id,
+ code: builtTrain.code,
+ trainName: builtTrain.trainName ?? null,
+ currentYardId: builtTrain.currentYardId ?? null,
+ },
+ limits: {
+ maxPullWeightTons,
+ overageToleranceTons,
+ pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
+ maxTrainLengthMeters,
+ overageToleranceMeters,
+ lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
+ },
+ totals: {
+ wagonCount: wagons.length,
+ cargoTons,
+ consistTareTons,
+ grossTons: roundTons(cargoTons + consistTareTons),
+ consistLengthMeters,
+ },
+ 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),
+ })),
+ addableWagons: addableWagons.map(mapWagon),
+ adjustments: adjustments.map((log) => ({
+ id: log.id,
+ action: log.action,
+ wagonId: log.wagonId,
+ wagonNumber: log.wagonNumber,
+ adjustedByUserId: log.adjustedByUserId,
+ occurredAt: log.occurredAt,
+ })),
+ editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
+ };
+ }
+
+ /**
+ * Permanently adjust the built train's consist from a schedule: trim free
+ * wagons (their tare no longer rides — the usual fix when gross weight beats
+ * the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
+ * length headroom remain (limits incl. overage tolerance). The built train
+ * updates in place, the schedule's wagon cap follows, and every change is
+ * logged for the schedule's history.
+ */
+ async adjustScheduleConsist(
+ scheduleId: string,
+ dto: AdjustScheduleConsistDto,
+ userId?: string | null,
+ ) {
+ const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
+ const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
+ if (!addWagonIds.length && !removeWagonIds.length) {
+ throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
+ }
+ const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
+ if (overlap.length) {
+ throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
+ }
+
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
+ if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
+ throw new BadRequestException(
+ 'The consist is frozen once the train is dispatched — adjust before departure',
+ );
+ }
+ const builtTrainRef = schedule.trainSet?.train;
+ if (!builtTrainRef) {
+ throw new BadRequestException(
+ 'This schedule was not created from a built train — its consist cannot be adjusted here',
+ );
+ }
+ const loadedWagonIds = new Set(
+ (schedule.trainSet?.wagons ?? [])
+ .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
+ .map((slot) => slot.physicalWagonId as string),
+ );
+ const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
+ const pullCapTons = roundTons(
+ Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
+ );
+ const lengthCapMeters = roundTons(
+ Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
+ );
+
+ await this.dataSource.transaction(async (manager) => {
+ const train = await manager.getRepository(Train).findOne({
+ where: { id: builtTrainRef.id },
+ lock: { mode: 'pessimistic_write' },
+ });
+ if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
+
+ const consist = await manager.getRepository(Wagon).find({
+ where: { trainId: train.id },
+ relations: { wagonType: true },
+ order: { sequenceNumber: 'ASC' },
+ });
+ const consistById = new Map(consist.map((w) => [w.id, w]));
+
+ // --- validate removals: must be coupled and free (no cargo, no pin) ---
+ 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) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
+ );
+ }
+ removed.push(wagon);
+ }
+
+ // --- validate additions: AVAILABLE, loose, standing in the train's yard ---
+ const added: Wagon[] = [];
+ for (const wagonId of addWagonIds) {
+ const wagon = await manager.getRepository(Wagon).findOne({
+ where: { id: wagonId },
+ relations: { wagonType: true },
+ lock: { mode: 'pessimistic_write' },
+ });
+ if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
+ if (wagon.trainId) {
+ throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
+ }
+ if (wagon.status !== WagonStatus.Available) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
+ );
+ }
+ if (wagon.currentYardId !== train.currentYardId) {
+ throw new BadRequestException(
+ `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
+ );
+ }
+ added.push(wagon);
+ }
+
+ // --- headroom check (only additions can push the train over a cap) ---
+ const removedIds = new Set(removed.map((w) => w.id));
+ const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
+ const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
+ const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
+ const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
+ const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
+ const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
+ const finalGrossTons = roundTons(cargoTons + finalTareTons);
+ if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
+ throw new BadRequestException(
+ `Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
+ );
+ }
+ if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
+ throw new BadRequestException(
+ `Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
+ );
+ }
+
+ // --- apply: detach trims, couple additions, compact the sequence ---
+ for (const wagon of removed) {
+ await manager.getRepository(Wagon).update(wagon.id, {
+ trainId: null,
+ sequenceNumber: null,
+ status: WagonStatus.Available,
+ });
+ }
+ const remaining = consist.filter((w) => !removedIds.has(w.id));
+ for (let i = 0; i < remaining.length; i++) {
+ if (remaining[i].sequenceNumber !== i + 1) {
+ await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
+ }
+ }
+ let sequence = remaining.length;
+ for (const wagon of added) {
+ sequence += 1;
+ await manager.getRepository(Wagon).update(wagon.id, {
+ trainId: train.id,
+ sequenceNumber: sequence,
+ status: WagonStatus.Assigned,
+ });
+ }
+
+ // The schedule is full when every consist wagon is allocated.
+ await manager
+ .getRepository(TrainSchedule)
+ .update(scheduleId, { maxWagons: finalConsist.length });
+
+ const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
+ const now = new Date();
+ await logRepo.save(
+ [
+ ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
+ ...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
+ ].map(({ action, wagon }) =>
+ logRepo.create({
+ trainScheduleId: scheduleId,
+ trainId: train.id,
+ action,
+ wagonId: wagon.id,
+ wagonNumber: wagon.wagonNumber,
+ adjustedByUserId: userId ?? null,
+ occurredAt: now,
+ }),
+ ),
+ );
+ });
+
+ return this.getScheduleConsist(scheduleId);
+ }
+
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
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 b92ab21d6..16ff29d38 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
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
+import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
@@ -511,9 +512,13 @@ export class TrainBuilderService {
startCount: number,
): Promise {
const uniqueIds = [...new Set(wagonIds)];
- let sequence = startCount;
+ const wagonRepo = manager.getRepository(Wagon);
+
+ // First pass: lock + validate every wagon so the length gate below sees
+ // the full incoming set before any row is written.
+ const toAttach: Wagon[] = [];
for (const wagonId of uniqueIds) {
- const wagon = await manager.getRepository(Wagon).findOne({
+ const wagon = await wagonRepo.findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
@@ -530,8 +535,16 @@ export class TrainBuilderService {
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
+ toAttach.push(wagon);
+ }
+ if (!toAttach.length) return;
+
+ await this.assertConsistLengthWithinLimit(manager, train, toAttach);
+
+ let sequence = startCount;
+ for (const wagon of toAttach) {
sequence += 1;
- await manager.getRepository(Wagon).update(wagon.id, {
+ await wagonRepo.update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
@@ -539,6 +552,58 @@ export class TrainBuilderService {
}
}
+ /**
+ * The consist (already-attached wagons + the incoming ones) must fit the
+ * train's locomotive length limit — the weakest locomotive of the set caps
+ * the train, mirroring how scheduling derives capacity.
+ */
+ private async assertConsistLengthWithinLimit(
+ manager: EntityManager,
+ train: Train,
+ incoming: Wagon[],
+ ): Promise {
+ const links = await manager.getRepository(TrainLocomotive).find({
+ where: { trainId: train.id },
+ relations: { locomotive: true },
+ });
+ const limits = minLocomotiveLimits(
+ links
+ .map((link) => link.locomotive)
+ .filter((loco): loco is Locomotive => Boolean(loco)),
+ );
+ const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
+ if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
+
+ const existing = await manager.getRepository(Wagon).find({
+ where: { trainId: train.id },
+ relations: { wagonType: true },
+ });
+ const currentLength = existing.reduce(
+ (sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
+ 0,
+ );
+
+ const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
+ const types = typeIds.length
+ ? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
+ : [];
+ const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
+ const addedLength = incoming.reduce(
+ (sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
+ 0,
+ );
+
+ const totalLength = currentLength + addedLength;
+ if (totalLength > maxLengthMeters) {
+ throw new BadRequestException(
+ `Cannot attach wagons — train length would be ${round(totalLength)} m ` +
+ `(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
+ `over the locomotive limit of ${round(maxLengthMeters)} m. ` +
+ 'Remove wagons from the consist or use locomotives with a higher length limit.',
+ );
+ }
+ }
+
/** Compact wagon sequence numbers back to 1..n after a removal. */
private async resequenceWagons(manager: EntityManager, trainId: string): Promise {
const wagons = await manager.getRepository(Wagon).find({
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
new file mode 100644
index 000000000..1ca91e01f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
@@ -0,0 +1,396 @@
+import {
+ Alert,
+ Badge,
+ Button,
+ Checkbox,
+ Divider,
+ Grid,
+ Group,
+ Modal,
+ Progress,
+ ScrollArea,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { AlertTriangle, History, Minus, Plus } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+import type { ConsistWagonRef } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
+const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
+const round2 = (v: number) => Math.round(v * 100) / 100;
+
+/**
+ * Adjust the built train's consist from a schedule: trim free wagons (their
+ * tare no longer rides — the fix when gross weight beats the pull limit) or
+ * couple extra yard wagons while weight/length headroom remains. Changes are
+ * permanent on the train and logged on the schedule.
+ */
+export default function AdjustConsistModal({
+ scheduleId,
+ opened,
+ onClose,
+}: AdjustConsistModalProps) {
+ const { toast } = useToast();
+ const [removeIds, setRemoveIds] = useState([]);
+ const [addIds, setAddIds] = useState([]);
+
+ const consistQuery = useQuery(
+ api.trainScheduling.scheduleConsist.queryOptions({
+ input: { scheduleId },
+ enabled: opened && Boolean(scheduleId),
+ }),
+ );
+ const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
+ const data = consistQuery.data;
+
+ useEffect(() => {
+ if (opened) {
+ setRemoveIds([]);
+ setAddIds([]);
+ }
+ }, [opened]);
+
+ // Live projection: gross = cargo + tare of (consist − trims + adds).
+ const projection = useMemo(() => {
+ if (!data) return null;
+ const removed = new Set(removeIds);
+ const keptTare = data.wagons
+ .filter((w) => !removed.has(w.id))
+ .reduce((s, w) => s + tareOf(w), 0);
+ const keptLength = data.wagons
+ .filter((w) => !removed.has(w.id))
+ .reduce((s, w) => s + lengthOf(w), 0);
+ const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
+ const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
+ const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
+ const gross = round2(data.totals.cargoTons + tare);
+ return {
+ wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
+ tare: round2(tare),
+ gross,
+ length: round2(length),
+ grossPct: data.limits.pullCapTons
+ ? Math.round((gross / data.limits.pullCapTons) * 100)
+ : null,
+ lengthPct: data.limits.lengthCapMeters
+ ? Math.round((length / data.limits.lengthCapMeters) * 100)
+ : null,
+ overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
+ overLength:
+ data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
+ };
+ }, [data, removeIds, addIds]);
+
+ const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
+ setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
+
+ const handleSubmit = async () => {
+ if (!removeIds.length && !addIds.length) return;
+ try {
+ await adjust.mutateAsync({
+ scheduleId,
+ payload: {
+ ...(addIds.length ? { addWagonIds: addIds } : {}),
+ ...(removeIds.length ? { removeWagonIds: removeIds } : {}),
+ },
+ });
+ toast({
+ title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
+ removeIds.length && addIds.length ? ", " : ""
+ }${addIds.length ? `${addIds.length} added` : ""}`,
+ });
+ setRemoveIds([]);
+ setAddIds([]);
+ } catch (err) {
+ toast({
+ title: "Adjustment failed",
+ description: parseError(err, "Could not adjust the consist"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+ Adjust consist{data ? ` — train ${data.train.code}` : ""}
+
+ }
+ radius="lg"
+ size={860}
+ centered
+ >
+ {consistQuery.isLoading || !data ? (
+
+ {consistQuery.isError
+ ? "This schedule has no built train to adjust."
+ : "Loading consist…"}
+
+ ) : (
+
+ {!data.editable ? (
+ }>
+ The consist is frozen once the train is dispatched.
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Trim coupled wagons ({data.totals.wagonCount})
+
+
+
+ Only free (unloaded, unpinned) wagons can be detached. Detaching is
+ permanent — the wagon returns to the yard as available.
+
+
+
+ {data.wagons.map((wagon) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Couple yard wagons ({data.addableWagons.length} available)
+
+
+
+ AVAILABLE wagons standing in the train's yard. Blocked when they push
+ gross weight or length past the locomotive limits incl. tolerance.
+
+
+
+ {data.addableWagons.length ? (
+ data.addableWagons.map((wagon) => (
+
+ ))
+ ) : (
+
+ No available wagons in this yard
+
+ )}
+
+
+
+
+
+
+ {data.adjustments.length ? (
+ <>
+
+
+
+
+
+ Adjustment history
+
+
+
+
+ {data.adjustments.map((log) => (
+
+
+ {log.action === "ADD" ? "Added" : "Trimmed"}
+
+
+ {log.wagonNumber}
+
+
+ {new Date(log.occurredAt).toLocaleString()}
+
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+
+
+ Projected consist: {projection?.wagonCount} wagons
+
+
+
+ Close
+
+ 0 && (projection?.overWeight || projection?.overLength))
+ }
+ onClick={handleSubmit}
+ >
+ Apply{" "}
+ {removeIds.length ? `−${removeIds.length}` : ""}
+ {removeIds.length && addIds.length ? " / " : ""}
+ {addIds.length ? `+${addIds.length}` : ""}
+
+
+
+
+ )}
+
+ );
+}
+
+export interface AdjustConsistModalProps {
+ scheduleId: string;
+ opened: boolean;
+ onClose: () => void;
+}
+
+function LimitGauge({
+ label,
+ detail,
+ pct,
+ over,
+}: {
+ label: string;
+ detail: string;
+ pct: number | null;
+ over: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {pct != null ? `${pct}%` : "—"}
+
+
+ 85 ? "yellow" : "edr-green"}
+ striped={over}
+ animated={over}
+ />
+
+ {detail}
+
+
+ );
+}
+
+function WagonRow({
+ wagon,
+ checked,
+ disabled,
+ badge,
+ onToggle,
+}: {
+ wagon: ConsistWagonRef;
+ checked: boolean;
+ disabled: boolean;
+ badge: string | null;
+ onToggle: (id: string, checked: boolean) => void;
+}) {
+ return (
+
+ onToggle(wagon.id, e.currentTarget.checked)}
+ aria-label={`Select wagon ${wagon.wagonNumber}`}
+ />
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
+ : "Unknown type"}
+
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index 0344e99cb..07a6c72cd 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Alert,
Badge,
Box,
Button,
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
+ // 20ft containers ride two per wagon, so a booking must hold an even number
+ // of them — odd counts would leave half a wagon waiting on a co-loader
+ // (cross-booking consolidation is disabled for now).
+ const twentyFtCount = useMemo(() => {
+ const sizeById = new Map();
+ for (const group of refData?.containers ?? []) {
+ for (const type of group.types) sizeById.set(type.id, group.size);
+ }
+ return lines.reduce((sum, l) => {
+ const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
+ return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
+ }, 0);
+ }, [refData?.containers, lines]);
+ const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
+
// ---- validation ----
const lineValid = (l: ContainerLine) =>
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
- : allLinesValid);
+ : allLinesValid && !hasOdd20ft);
const updateLine = (key: string, patch: Partial) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
) : null}
+ {hasOdd20ft ? (
+ } radius="md" mt="md">
+
+ Odd number of 20ft containers ({twentyFtCount})
+
+
+ 20ft containers travel two per wagon, so they must be booked in
+ even numbers. Add one more 20ft container or remove one — e.g.
+ book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
+ {twentyFtCount}.
+
+
+ ) : null}
+
("overview");
+ const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState(
null,
);
@@ -870,6 +872,17 @@ export default function BatchScheduleDetailPage() {
>
Refresh
+ {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
+ }
+ onClick={() => setAdjustConsistOpen(true)}
+ >
+ Adjust consist
+
+ ) : null}
{data.windowPhase === "DOC_REVIEW" ? (
+
+ setAdjustConsistOpen(false)}
+ />
);
}
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 c9c4a042a..2fa5b2ee0 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -52,6 +52,7 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
+import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -101,6 +102,7 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState(null);
const [containerPlacements, setContainerPlacements] = useState([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
+ const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -949,6 +951,18 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
) : null}
+ {schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
+ }
+ onClick={() => setAdjustConsistOpen(true)}
+ >
+ Adjust consist
+
+ ) : null}
{gatepassApplies ? (
gatepassSecured ? (
) : null}
+ setAdjustConsistOpen(false)}
+ />
+
QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
),
+ scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
+ "train-scheduling",
+ "schedule-consist",
+ ({ scheduleId }) =>
+ trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
+ ({ scheduleId }) => [
+ ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
+ "consist",
+ scheduleId,
+ ],
+ ),
+
+ adjustConsist: endpoint<
+ { scheduleId: string; payload: AdjustConsistPayload },
+ ScheduleConsist
+ >(
+ "train-scheduling",
+ "adjust-consist",
+ ({ scheduleId, payload }) =>
+ trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index e6c462992..d88cd3ed6 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -153,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
return qs ? `?${qs}` : "";
};
+// ---------------------------------------------------------------------------
+// Schedule consist adjustment (train-bound schedules)
+// ---------------------------------------------------------------------------
+
+export interface ConsistWagonRef {
+ id: string;
+ wagonNumber: string;
+ sequenceNumber: number | null;
+ wagonType: {
+ id: string;
+ code: string;
+ tareWeightTons: number;
+ capacityTons: number;
+ lengthMeters: number;
+ } | null;
+}
+
+export interface ScheduleConsist {
+ schedule: { id: string; reference: string | null; status: string };
+ train: {
+ id: string;
+ code: string;
+ trainName: string | null;
+ currentYardId: string | null;
+ };
+ limits: {
+ maxPullWeightTons: number;
+ overageToleranceTons: number;
+ pullCapTons: number;
+ maxTrainLengthMeters: number;
+ overageToleranceMeters: number;
+ lengthCapMeters: number;
+ };
+ totals: {
+ wagonCount: number;
+ cargoTons: number;
+ consistTareTons: number;
+ grossTons: number;
+ consistLengthMeters: number;
+ };
+ wagons: Array;
+ addableWagons: ConsistWagonRef[];
+ adjustments: Array<{
+ id: string;
+ action: "ADD" | "REMOVE";
+ wagonId: string;
+ wagonNumber: string;
+ adjustedByUserId: string | null;
+ occurredAt: string;
+ }>;
+ editable: boolean;
+}
+
+export interface AdjustConsistPayload {
+ addWagonIds?: string[];
+ removeWagonIds?: string[];
+}
+
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get(`${BASE}${toQuery(filters)}`),
@@ -175,4 +233,13 @@ export const trainBuilderService = {
apiClient.get(`/train-scheduling/available-trains`, {
params: { routeId },
}),
+ /** Consist snapshot for a train-bound schedule (adjust-consist UI). */
+ scheduleConsist: (scheduleId: string) =>
+ apiClient.get(`/train-scheduling/schedules/${scheduleId}/consist`),
+ /** Permanently trim/add wagons on the schedule's built train. */
+ adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
+ apiClient.post(
+ `/train-scheduling/schedules/${scheduleId}/adjust-consist`,
+ payload,
+ ),
};
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index b582468cf..b9cf743fa 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -787,13 +787,17 @@ export function Step5CargoDetails({
const result = calcWagons(containers ?? []);
if (result.hasOddUnit) {
return (
-
- Unpaired 20ft Container
+
+
+ Odd number of 20ft containers ({result.ft20Wagons})
+
- One 20ft container occupies only half a wagon. The wagon will
- depart once a co-loader is found to fill the remaining slot,
- which may delay departure beyond the standard
- lead time.
+ 20ft containers travel two per wagon, so they must be booked in
+ even numbers. Please add one more 20ft container {" "}
+ or remove one (e.g. book{" "}
+ {result.ft20Wagons + 1} or {result.ft20Wagons - 1} instead of{" "}
+ {result.ft20Wagons}) — the booking cannot be submitted with an
+ unpaired 20ft container.
);
From b102a31fc1a658077e94ee141f6eed07073c6169 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:51:31 +0000
Subject: [PATCH 11/67] fix train builder and consolidation
---
.../new-booking-form/step8-review.tsx | 36 +++++++++++++++----
1 file changed, 30 insertions(+), 6 deletions(-)
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 a789943c2..501a04a5d 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
@@ -25,10 +25,11 @@ import {
} from "lucide-react";
import type { Freight } from "@/types";
import {
+ calcWagons,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
-import { StepHeader } from "./shared";
+import { AlertBox, StepHeader } from "./shared";
export const REVIEW_STEP_TARGETS = {
contract: 1,
@@ -160,6 +161,12 @@ export function Step8Review({
.join(", ")
: "";
+ // 20ft containers must pair up (two per wagon) — an odd total blocks submit.
+ const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
+ values.cargoType === "container"
+ ? calcWagons(values.containers ?? [])
+ : { hasOddUnit: false, ft20Wagons: 0 };
+
const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
@@ -529,10 +536,27 @@ export function Step8Review({
-
- Ready to submit. You'll review the unit rates before final
- submission.
-
+ {hasOdd20ft ? (
+
+
+
+ Odd number of 20ft containers ({twentyFtCount})
+
+
+ 20ft containers travel two per wagon, so they must be booked
+ in even numbers. Go back to the cargo step and{" "}
+ add one more 20ft container or{" "}
+ remove one (e.g. {twentyFtCount + 1} or{" "}
+ {twentyFtCount - 1} instead of {twentyFtCount}).
+
+
+
+ ) : (
+
+ Ready to submit. You'll review the unit rates before final
+ submission.
+
+ )}
}
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending}
+ disabled={submitPending || hasOdd20ft}
>
Submit
From 12ab3f33b109f4803f39fbbebba15138618cbbb3 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 16:54:24 +0300
Subject: [PATCH 12/67] Fix stop route
---
.../src/modules/bookings/bookings.service.ts | 52 ++++++++++++++++---
1 file changed, 46 insertions(+), 6 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 7f09ffa49..a4b3617c9 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1728,13 +1728,40 @@ export class BookingsService {
);
}
+ // Resolves the passenger's actual boarding/alighting stations for one leg from
+ // originStationId/destinationStationId (set when the booking covers only part of a
+ // longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
+ // the schedule's stopTimes, falling back to the schedule's own full-route endpoints
+ // when there's no segment override (older records, or a booking that covers the
+ // whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
+ // already applied to SMS/email; this brings the booking API (voucher, detail page,
+ // confirmation) to the same behavior instead of always showing the train's full route.
+ private resolveSegmentStations(
+ schedule: any,
+ originStationId: string | null | undefined,
+ destinationStationId: string | null | undefined,
+ ): { origin: any; destination: any } {
+ const stopTimes: any[] = schedule?.stopTimes ?? [];
+ const findStation = (stationId: string | null | undefined, fallback: any) => {
+ if (stationId && stopTimes.length > 0) {
+ const stop = stopTimes.find((st: any) => st.stationId === stationId);
+ if (stop?.station) return stop.station;
+ }
+ return fallback ?? null;
+ };
+ return {
+ origin: findStation(originStationId, schedule?.originStation),
+ destination: findStation(destinationStationId, schedule?.destinationStation),
+ };
+ }
+
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
- schedule: { include: { originStation: true, destinationStation: true, train: true } },
- returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
+ returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
@@ -1803,6 +1830,19 @@ export class BookingsService {
};
}
+ const outboundSegment = this.resolveSegmentStations(
+ (booking as any).schedule,
+ (booking as any).originStationId,
+ (booking as any).destinationStationId,
+ );
+ const returnSegment = (booking as any).returnSchedule
+ ? this.resolveSegmentStations(
+ (booking as any).returnSchedule,
+ (booking as any).returnOriginStationId,
+ (booking as any).returnDestinationStationId,
+ )
+ : null;
+
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
@@ -1819,8 +1859,8 @@ export class BookingsService {
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
- origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
- destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
+ origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
+ destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
@@ -1828,8 +1868,8 @@ export class BookingsService {
id: (booking as any).returnSchedule.id,
trainNumber: (booking as any).returnSchedule.train.number,
trainName: (booking as any).returnSchedule.train.name,
- origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
- destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
+ origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
+ destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
}
: null,
From 7e934b34338e65b26a1e574c7c0e6e4d75c8ab4f Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 14:14:02 +0000
Subject: [PATCH 13/67] feat(warehouse,last-mile): truck load size rule, dedup
last-mile, driver-required + arrival prefill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Truck loading:
- loadTruck enforces max 2 containers / one 40ft (two 20ft) and auto-marks an
assigned truck arrived on load; container-items payload + modal expose
container size with a client-side selection cap.
- Show "#x containers pending assignment" in the portal truck card and the
backoffice container modal.
Last-mile:
- create() is idempotent — return the existing record for a booking instead of
inserting a duplicate delivery row (fixed the same booking showing twice in
Assign-Mile).
- setVehicles/update reject a truck with no assigned driver; the Assign toast
now surfaces the reason.
- New GET /last-mile/booking/:id/arrival-trucks returns assigned EDR trucks with
driver details; ReleaseOrderModal fetches and auto-fills them so an assigned
EDR truck no longer reads as "not assigned yet".
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../bookings/customer-truck.service.ts | 34 ++++--
.../bookings/dto/load-customer-truck.dto.ts | 4 +-
.../modules/last-mile/last-mile.controller.ts | 6 +
.../modules/last-mile/last-mile.service.ts | 110 ++++++++++++++++++
.../warehouses/warehouse-inventory.service.ts | 4 +
.../warehouses/ContainerItemsModal.tsx | 60 ++++++++--
.../warehouses/ReleaseOrderModal.tsx | 36 ++++++
.../src/pages/operations/LastMilePage.tsx | 8 +-
.../src/services/warehouse.service.ts | 20 ++++
.../CustomerTruckAssignmentCard.tsx | 21 +++-
10 files changed, 278 insertions(+), 25 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
index b61d0078d..f366faa96 100644
--- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
@@ -328,18 +328,21 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
- // Containers can only be loaded after the truck has physically arrived at the
- // warehouse (arrival weighing recorded). Assignment alone is just planning.
- if (!assignment.arrivedAt) {
- throw new BadRequestException(
- 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
- );
- }
+ // Loading a truck at the warehouse implies it is physically present, so a
+ // truck that is still only assigned (not yet marked arrived) is auto-arrived
+ // here rather than blocking the operator — the real gross is weighed on
+ // departure anyway.
+ const needsArrival = !assignment.arrivedAt;
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
+ // Capacity is size-based: a truck carries at most 2 containers, and a 40ft
+ // container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
+ if (requested.length > 2) {
+ throw new BadRequestException('A truck carries at most 2 containers');
+ }
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
@@ -352,6 +355,12 @@ export class CustomerTruckService {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
+ const sizes = await this.containerSizes(bookingId, requested);
+ if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
+ throw new BadRequestException(
+ 'A 40ft container fills the truck — load only 1 container onto this truck',
+ );
+ }
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
@@ -371,9 +380,20 @@ export class CustomerTruckService {
);
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
+ // Auto-stamp arrival if the truck was still only assigned.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossTons,
+ ...(needsArrival ? { arrivedAt: new Date() } : {}),
});
+ if (needsArrival) {
+ await manager.query(
+ `UPDATE freight.bookings
+ SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
+ updated_at = NOW()
+ WHERE id = $1`,
+ [bookingId],
+ );
+ }
});
return this.listTrucks(bookingId);
}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts
index 11c80f687..1386e5bd4 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts
@@ -1,9 +1,11 @@
-import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
+import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
+ // A truck carries at most 2 containers (two 20ft, or one 40ft).
+ @ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
index abee4d53d..fa7ee59ec 100644
--- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
+++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
@@ -67,6 +67,12 @@ export class LastMileController {
return this.lastMileService.findById(id);
}
+ @Get('booking/:bookingId/arrival-trucks')
+ @ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
+ arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
+ return this.lastMileService.arrivalTrucksForBooking(bookingId);
+ }
+
@Post('accept/:reference')
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
index 999137e6e..1ee31c63a 100644
--- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
+++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts
@@ -256,7 +256,95 @@ export class LastMileService {
return this.findById(id);
}
+ /**
+ * The EDR last-mile trucks assigned to a booking, joined with driver details,
+ * shaped for the arrival/exit weighing prefill (plate, driver, type, container).
+ * Returns [] when the booking has no last-mile truck assigned. Lets the
+ * warehouse arrival/load modals surface an assigned EDR truck the same way the
+ * self-haul customer trucks are surfaced.
+ */
+ async arrivalTrucksForBooking(bookingId: string): Promise<
+ Array<{
+ vehicleId: string;
+ truckPlateNumber: string | null;
+ trailerPlateNumber: string | null;
+ driverName: string | null;
+ driverLicense: string | null;
+ driverPhone: string | null;
+ truckType: string | null;
+ containerNumber: string | null;
+ }>
+ > {
+ const [lm] = await this.lastMileRepository.findAll({
+ where: { bookingId },
+ relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
+ take: 1,
+ });
+ if (!lm) return [];
+
+ // Prefer the multi-truck junction; fall back to the legacy single vehicle.
+ const sources = lm.vehicleAssignments?.length
+ ? lm.vehicleAssignments.map((va) => ({
+ vehicle: va.vehicle,
+ containerNumber: va.containerNumber ?? null,
+ }))
+ : lm.vehicle
+ ? [{ vehicle: lm.vehicle, containerNumber: null }]
+ : [];
+
+ const out: Array<{
+ vehicleId: string;
+ truckPlateNumber: string | null;
+ trailerPlateNumber: string | null;
+ driverName: string | null;
+ driverLicense: string | null;
+ driverPhone: string | null;
+ truckType: string | null;
+ containerNumber: string | null;
+ }> = [];
+ for (const { vehicle, containerNumber } of sources) {
+ if (!vehicle) continue;
+ let driverName = vehicle.assignedDriverName ?? null;
+ let driverLicense: string | null = null;
+ let driverPhone: string | null = null;
+ if (vehicle.assignedDriverId) {
+ try {
+ const d = await this.driversService.findById(vehicle.assignedDriverId);
+ driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null;
+ driverLicense = d.licenseNumber ?? null;
+ driverPhone = d.phoneNumber ?? null;
+ } catch {
+ /* driver lookup is best-effort — plate still prefills */
+ }
+ }
+ out.push({
+ vehicleId: vehicle.id,
+ truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
+ trailerPlateNumber: vehicle.trailerPlateNo || null,
+ driverName,
+ driverLicense,
+ driverPhone,
+ truckType: vehicle.vehicleType || null,
+ containerNumber,
+ });
+ }
+ return out;
+ }
+
async create(dto: CreateLastMileDto): Promise {
+ // Idempotent: a booking gets exactly one last-mile record. Extra trucks live
+ // inside that record (vehicleAssignments), never as additional rows — so if a
+ // last-mile already exists for this booking, return it instead of inserting a
+ // duplicate delivery row (which is what made the same booking appear twice in
+ // the Assign-Mile list).
+ const [existing] = await this.lastMileRepository.findAll({
+ where: { bookingId: dto.bookingId },
+ take: 1,
+ });
+ if (existing) {
+ return existing;
+ }
+
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -318,6 +406,17 @@ export class LastMileService {
}
}
+ // A last-mile truck must have a driver before it can be assigned (same rule
+ // as setVehicles) — block driverless single-vehicle (re)assignment too.
+ if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) {
+ const vehicle = await this.vehiclesService.findById(dto.vehicleId);
+ if (!vehicle?.assignedDriverId) {
+ throw new BadRequestException(
+ `Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
+ );
+ }
+ }
+
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -478,6 +577,17 @@ export class LastMileService {
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
+
+ // A last-mile truck must have a driver before it can be assigned — a delivery
+ // can't run driverless, and the arrival/exit weighing needs the driver.
+ for (const vehicleId of added) {
+ const vehicle = await this.vehiclesService.findById(vehicleId);
+ if (!vehicle?.assignedDriverId) {
+ throw new BadRequestException(
+ `Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
+ );
+ }
+ }
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>
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 1c1be66ec..9a42bc895 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
@@ -2830,6 +2830,7 @@ export class WarehouseInventoryService {
Array<{
containerNumber: string;
goods: string | null;
+ containerSize: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -2846,6 +2847,7 @@ export class WarehouseInventoryService {
const rows: Array<{
containerNumber: string;
goods: string | null;
+ containerSize: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -2860,6 +2862,7 @@ export class WarehouseInventoryService {
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
+ bc.container_size AS "containerSize",
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
@@ -2896,6 +2899,7 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
+ containerSize: r.containerSize,
// A container the customer assigned to a truck is ASSIGNED (planned); it
// only becomes LOADED once the operator loads it (loaded_at) on truck
// leaving. Departed → LEFT, delivered → DELIVERED.
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
index 57be67d68..0a2dd5b7f 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
@@ -80,14 +80,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
- // Only arrived, not-yet-departed trucks can be loaded.
+ // Any assigned, not-yet-departed truck can be loaded here — loading a truck at
+ // the warehouse auto-marks it arrived on the backend, so assigned-but-not-yet-
+ // arrived trucks are selectable too (labelled "assigned" until they arrive).
const truckOptions = trucks
- .filter(
- (t) =>
- Boolean((t as { arrivedAt?: string }).arrivedAt) &&
- !(t as { departedAt?: string }).departedAt,
- )
- .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
+ .filter((t) => !(t as { departedAt?: string }).departedAt)
+ .map((t) => ({
+ value: t.id,
+ label: `${t.plateNumber} · ${t.driverName}${
+ (t as { arrivedAt?: string }).arrivedAt ? '' : ' (assigned)'
+ }`,
+ }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
@@ -125,7 +128,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
}
};
- const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
+ const is40 = (n: string) =>
+ (items.find((i) => i.containerNumber === n)?.containerSize ?? '').includes('40');
+
+ // A truck carries at most 2 containers, and a 40ft fills the truck (max 1).
+ const toggle = (n: string) =>
+ setSelected((s) => {
+ if (s.includes(n)) return s.filter((x) => x !== n);
+ const next = [...s, n];
+ if (next.length > 2) {
+ toast({ variant: 'destructive', title: 'A truck carries at most 2 containers' });
+ return s;
+ }
+ if (next.length > 1 && next.some(is40)) {
+ toast({
+ variant: 'destructive',
+ title: 'A 40ft container fills the truck',
+ description: 'Load only one 40ft container per truck.',
+ });
+ return s;
+ }
+ return next;
+ });
return (
Container
+ Size
Goods
Stage
Truck
@@ -182,6 +207,15 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
/>
{i.containerNumber}
+
+ {i.containerSize ? (
+
+ {i.containerSize}
+
+ ) : (
+ bulk
+ )}
+
{i.goods ?? '—'}
{i.stage}
{i.truckPlate ?? '—'}
@@ -221,11 +255,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
{/* Multiselect → load onto a truck */}
- {selected.length} selected
+
+ {selected.length} selected
+ {(() => {
+ const pending = items.filter((i) => !i.truckAssignmentId).length;
+ return pending > 0 ? ` · ${pending} container${pending === 1 ? '' : 's'} pending assignment` : '';
+ })()}
+
warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
+ // EDR last-mile trucks assigned to this booking — surfaced even when the modal
+ // is opened from the warehouse flow (which passes no truckPrefill prop), so an
+ // assigned EDR truck no longer shows as "not assigned yet".
+ const { data: lastMileTrucks = [] } = useQuery({
+ queryKey: ['release-last-mile-trucks', bookingId],
+ queryFn: () => warehouseService.getLastMileTrucks(bookingId as string),
+ enabled: opened && Boolean(bookingId),
+ });
// Per-container cargo weights — the truck's net (gross − tare) must equal the
// total cargo weight of the containers selected as loaded on it.
const { data: containerWeights = [] } = useQuery({
@@ -176,6 +184,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
+ // Opened from the warehouse flow (no truckPrefill prop): once the last-mile
+ // truck query resolves, auto-fill the first assigned EDR truck — without
+ // overwriting anything the operator typed or the locked exit-step values.
+ useEffect(() => {
+ if (!opened || truckPrefill || isExitStep) return;
+ const first = lastMileTrucks[0];
+ if (!first) return;
+ setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
+ setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
+ setDriverName((p) => p || first.driverName || '');
+ setDriverLicense((p) => p || first.driverLicense || '');
+ setDriverPhone((p) => p || first.driverPhone || '');
+ setTruckType((p) => p || first.truckType || '');
+ setContainerNumbers((prev) =>
+ prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
+ );
+ }, [opened, truckPrefill, isExitStep, lastMileTrucks]);
+
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
@@ -199,6 +225,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverPhone: '',
truckType: t.truckType,
})),
+ ...lastMileTrucks
+ .filter((t) => t.truckPlateNumber || t.vehicleId)
+ .map((t) => ({
+ value: (t.truckPlateNumber || t.vehicleId) as string,
+ label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
+ trailerPlate: t.trailerPlateNumber ?? '',
+ driverName: t.driverName ?? '',
+ driverPhone: t.driverPhone ?? '',
+ truckType: t.truckType ?? '',
+ })),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
index 062332107..e5de54880 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -666,8 +666,12 @@ const LastMilePage = () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
- onError: () => {
- toast({ title: "Assign failed", variant: "destructive" });
+ onError: (e: unknown) => {
+ // Surface the backend reason (e.g. "Truck … has no assigned driver …").
+ const raw = (e as { response?: { data?: { message?: string | string[] } } })?.response?.data
+ ?.message;
+ const description = Array.isArray(raw) ? raw.join(", ") : raw;
+ toast({ title: "Assign failed", description, variant: "destructive" });
},
});
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 febde7f85..53ba263a5 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -71,6 +71,8 @@ export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | '
export interface ContainerItem {
containerNumber: string;
goods: string | null;
+ /** Container size, e.g. "20ft" / "40ft"; null for bulk. */
+ containerSize: string | null;
stage: ContainerItemStage;
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -126,6 +128,18 @@ const cleanParams = (params: object) =>
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
);
+/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
+export interface LastMileArrivalTruck {
+ vehicleId: string;
+ truckPlateNumber: string | null;
+ trailerPlateNumber: string | null;
+ driverName: string | null;
+ driverLicense: string | null;
+ driverPhone: string | null;
+ truckType: string | null;
+ containerNumber: string | null;
+}
+
export const warehouseService = {
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
getCustomerTrucks: async (bookingId: string): Promise => {
@@ -133,6 +147,12 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
+ /** Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill). */
+ getLastMileTrucks: async (bookingId: string): Promise => {
+ const { data } = await apiClient.get(`/last-mile/booking/${bookingId}/arrival-trucks`);
+ return data?.data ?? data ?? [];
+ },
+
/** Per-container/bulk items of a booking with lifecycle stage + refs. */
getContainerItems: async (bookingId: string): Promise => {
const { data } = await apiClient.get(
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
index a73057fcd..7c9cbeb0e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
@@ -77,6 +77,10 @@ export function CustomerTruckAssignmentCard({
const availableContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
+ // Containers on the booking not yet assigned to any truck (independent of edit).
+ const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
+ (n) => !assignedNumbers.has(n),
+ ).length;
// Both import and export specify the containers each truck carries.
const resetForm = () => {
@@ -158,11 +162,18 @@ export function CustomerTruckAssignmentCard({
External Truck Assignment
- {trucks.length > 0 && (
-
- {trucks.length} truck{trucks.length !== 1 ? "s" : ""}
-
- )}
+
+ {pendingAssignmentCount > 0 && (
+
+ {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
+
+ )}
+ {trucks.length > 0 && (
+
+ {trucks.length} truck{trucks.length !== 1 ? "s" : ""}
+
+ )}
+
{/* Assigned trucks */}
From fc684a0f65c139129434ac61558a3d000f77220e Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Tue, 14 Jul 2026 20:04:42 +0300
Subject: [PATCH 14/67] feat: ( permissions ) add permssions for master data
---
.../src/modules/fleet/fleet.controller.ts | 15 ++-
.../modules/packages/packages.controller.ts | 24 ++--
.../modules/payments/payments.controller.ts | 15 ++-
.../modules/schedules/routes.controller.ts | 14 +--
.../modules/schedules/schedules.controller.ts | 29 ++---
.../seat-classes/seat-classes.controller.ts | 10 +-
.../src/modules/seats/seats.controller.ts | 15 +--
.../modules/stations/stations.controller.ts | 14 +--
.../src/seed/edr-passenger.seed.ts | 15 +++
.../seed/passenger-permissions.registry.ts | 107 ++++++++++++++++++
.../src/app/payment-methods/page.tsx | 9 +-
.../src/components/layout/Sidebar.tsx | 26 ++---
.../backoffice/src/lib/permissions.ts | 69 +++++++++--
13 files changed, 282 insertions(+), 80 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
index d3864e2b2..ee2a048f0 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet')
@Controller('fleet')
@@ -21,6 +22,7 @@ export class FleetController {
}
@Post('coach-types')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
@@ -29,6 +31,7 @@ export class FleetController {
}
@Patch('coach-types/:id')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@@ -59,6 +62,7 @@ export class FleetController {
}
@Post('classes')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -67,6 +71,7 @@ export class FleetController {
}
@Patch('classes/:id')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -98,6 +103,7 @@ export class FleetController {
}
@Post('seat-classes')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -106,6 +112,7 @@ export class FleetController {
}
@Patch('seat-classes/:id')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -136,6 +143,7 @@ export class FleetController {
}
@Post('trains')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
@@ -144,6 +152,7 @@ export class FleetController {
}
@Patch('trains/:id')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto })
@@ -166,6 +175,7 @@ export class FleetController {
}
@Patch('trains/:id/restore')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@@ -268,6 +278,7 @@ export class FleetController {
}
@Post('coaches')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({
@@ -293,6 +304,7 @@ export class FleetController {
}
@Patch('coaches/:id')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@@ -331,6 +343,7 @@ export class FleetController {
}
@Post('assignments')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' })
diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
index 314f8aeb4..79f04f9d9 100644
--- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
+++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
-import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages')
@Controller('packages')
@@ -21,7 +21,7 @@ export class PackagesController {
}
@Get('inquiries')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
listInquiries(
@@ -34,7 +34,7 @@ export class PackagesController {
}
@Patch('inquiries/:id/status')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -57,7 +57,7 @@ export class PackagesController {
}
@Get('all')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all packages (backoffice)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
@@ -65,7 +65,7 @@ export class PackagesController {
}
@Get('bookings')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
listBookings(
@@ -124,7 +124,7 @@ export class PackagesController {
}
@Post()
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
@@ -132,7 +132,7 @@ export class PackagesController {
}
@Patch(':id')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial) {
@@ -149,7 +149,7 @@ export class PackagesController {
}
@Patch(':id/activate')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
@@ -157,7 +157,7 @@ export class PackagesController {
}
@Patch(':id/deactivate')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
@@ -165,7 +165,7 @@ export class PackagesController {
}
@Post(':id/tiers')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -173,7 +173,7 @@ export class PackagesController {
}
@Patch('tiers/:tierId')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 8917c75d3..f917a9590 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -55,7 +55,11 @@ export class PaymentsController {
}
@Get("all")
- @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
+ @PassengerStaff([
+ PASSENGER_PERMS.payments.view,
+ PASSENGER_PERMS.payments.viewAll,
+ PASSENGER_PERMS.admin,
+ ])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false })
@@ -146,7 +150,11 @@ export class PaymentsController {
}
@Post("refund")
- @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
+ @PassengerStaff([
+ PASSENGER_PERMS.payments.manage,
+ PASSENGER_PERMS.payments.refund,
+ PASSENGER_PERMS.admin,
+ ])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
refund(@Body() dto: RefundDto) {
@@ -155,6 +163,7 @@ export class PaymentsController {
@Post(":bookingId/force-confirm")
@PassengerStaff([
+ PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -174,6 +183,7 @@ export class PaymentsController {
@Post("methods")
@PassengerStaff([
+ PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -187,6 +197,7 @@ export class PaymentsController {
@Patch("methods/:id")
@PassengerStaff([
+ PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
index e751278ce..d468bba7c 100644
--- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
@@ -1,9 +1,9 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes')
@Controller('routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ─────────────────────────────────────────────────────────────────
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' })
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
index 9fc2ba851..a2d62d5aa 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Schedule')
@Controller('schedules')
@@ -12,14 +12,14 @@ export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +42,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -65,7 +65,7 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@@ -76,7 +76,7 @@ export class SchedulesController {
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@@ -97,7 +97,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -105,7 +105,7 @@ export class SchedulesController {
}
@Patch(':id/status')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
@@ -127,7 +127,7 @@ export class SchedulesController {
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -138,7 +138,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); }
@Put(':scheduleId/fares/:seatClassId')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
@@ -186,12 +186,13 @@ export class SchedulesController {
}
@Post(':id/fares/sync')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches(
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
index 4eb6c212b..453256d7e 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes')
@Controller('seat-classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto })
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
index fa233f45c..752b66390 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
@@ -21,7 +21,8 @@ import {
import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
-import { IamGuard } from "../../common/iam-adapter";
+import { PassengerStaff } from "../../common/passenger-guards";
+import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats")
@Controller("seats")
@@ -205,7 +206,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -215,7 +216,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/block")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -226,7 +227,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -236,7 +237,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/maintenance")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -247,7 +248,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Remove a seat by marking with negative seatNumber",
@@ -263,7 +264,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Patch(":seatId/undo-remove")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Undo seat removal by restoring original seatNumber",
diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
index 0f367043b..5ddd64383 100644
--- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
+++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations')
@Controller('stations')
@@ -79,8 +79,8 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
+ @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
status: 201,
@@ -105,8 +105,8 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
+ @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
status: 200,
diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
index 578cf66ff..4313d5426 100644
--- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
+++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
@@ -54,4 +54,19 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
name: { en: 'EDR Passenger Finance' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
},
+ {
+ key: 'edr_passenger_operations_manager',
+ name: { en: 'EDR Passenger Operations Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsManager],
+ },
+ {
+ key: 'edr_passenger_marketing_manager',
+ name: { en: 'EDR Passenger Marketing Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.marketingManager],
+ },
+ {
+ key: 'edr_passenger_finance_manager',
+ name: { en: 'EDR Passenger Finance Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager],
+ },
];
diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
index bab7787b9..d62d5a261 100644
--- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
+++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
@@ -34,6 +34,38 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'),
perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'),
+
+ // ── Master Data ────────────────────────────────────────────────────────────
+ perm('102969bc-13a2-4f4a-aa7f-ce4b2599ce82', 'edr_passenger_app:stations:view', 'View stations'),
+ perm('931cf6fc-8f41-46b6-82b8-b242f75d296e', 'edr_passenger_app:stations:manage', 'Manage stations'),
+ perm('82a801b3-2451-409b-99e8-9f4d3515d329', 'edr_passenger_app:trains:view', 'View trains'),
+ perm('e2a7f842-7691-4007-bd71-fd91c467044d', 'edr_passenger_app:trains:manage', 'Manage trains'),
+ perm('71de593c-ae4a-4d15-9f0a-b889eeb4910c', 'edr_passenger_app:coaches:view', 'View coaches'),
+ perm('39b3de2e-c779-4010-8ef3-d478f00a15c6', 'edr_passenger_app:coaches:manage', 'Manage coaches'),
+ perm('ba0fdd0b-6580-48a8-b31f-eb5ef76a2453', 'edr_passenger_app:seats:view', 'View seats'),
+ perm('6fb9affb-1885-446e-a8e4-962af9fae33f', 'edr_passenger_app:seats:manage', 'Manage seats'),
+ perm('b7b659d9-f453-41d3-a6db-72e671446214', 'edr_passenger_app:classes:view', 'View classes'),
+ perm('b2d06665-33f5-46d7-a895-785b5fb1896a', 'edr_passenger_app:classes:manage', 'Manage classes'),
+ perm('8731ee98-24c2-4f8b-9c06-cf3fa900a95a', 'edr_passenger_app:routes:view', 'View routes'),
+ perm('5851233c-78de-45b9-9d3f-63816d068622', 'edr_passenger_app:routes:manage', 'Manage routes'),
+ perm('c453bdf9-496a-4ac8-b733-8eb5dd5d591a', 'edr_passenger_app:schedules:view', 'View schedules'),
+ perm('d3f3cfd0-c7ce-47ab-be7f-bf3d6b40e488', 'edr_passenger_app:schedules:manage', 'Manage schedules'),
+
+ // ── Tourism ────────────────────────────────────────────────────────────────
+ perm('d78d810b-3003-4d81-92d5-41c437f3cc42', 'edr_passenger_app:packages:view', 'View packages'),
+ perm('dcfab0d9-1f80-4822-892a-e2851b549297', 'edr_passenger_app:packages:manage', 'Manage packages'),
+ perm('6d7ab68c-1b88-405d-9f92-b130055eece6', 'edr_passenger_app:inquiries:view', 'View package inquiries'),
+ perm('dbe5a07a-d12f-4a36-b191-0bb4f980054e', 'edr_passenger_app:inquiries:manage', 'Manage package inquiries'),
+
+ // ── Finance ────────────────────────────────────────────────────────────────
+ perm('4b6efd87-f230-4109-abe1-593e53cb0c10', 'edr_passenger_app:tariff_rates:view', 'View tariff rates'),
+ perm('94f17a59-397c-4c9c-a424-38a9c66c9e50', 'edr_passenger_app:tariff_rates:manage', 'Manage tariff rates'),
+ perm('2dc4eb75-5b28-4ead-a2d4-82ac95cd290c', 'edr_passenger_app:payments:view', 'View payments'),
+ perm('418b5f64-656b-4d44-a543-930ada9ec1a7', 'edr_passenger_app:payments:manage', 'Manage payments'),
+ perm('3f3d5479-af33-4883-867e-aae9e2aeeeca', 'edr_passenger_app:currencies:view', 'View currencies'),
+ perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'),
+ perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'),
+
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
];
@@ -54,10 +86,57 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:tickets:manage',
},
payments: {
+ view: 'edr_passenger_app:payments:view',
+ manage: 'edr_passenger_app:payments:manage',
+ // legacy keys — retained as aliases for backward compatibility
viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
+ paymentMethods: {
+ view: 'edr_passenger_app:payment_methods:view',
+ manage: 'edr_passenger_app:payment_methods:manage',
+ },
+ stations: {
+ view: 'edr_passenger_app:stations:view',
+ manage: 'edr_passenger_app:stations:manage',
+ },
+ trains: {
+ view: 'edr_passenger_app:trains:view',
+ manage: 'edr_passenger_app:trains:manage',
+ },
+ coaches: {
+ view: 'edr_passenger_app:coaches:view',
+ manage: 'edr_passenger_app:coaches:manage',
+ },
+ seats: {
+ view: 'edr_passenger_app:seats:view',
+ manage: 'edr_passenger_app:seats:manage',
+ },
+ classes: {
+ view: 'edr_passenger_app:classes:view',
+ manage: 'edr_passenger_app:classes:manage',
+ },
+ routes: {
+ view: 'edr_passenger_app:routes:view',
+ manage: 'edr_passenger_app:routes:manage',
+ },
+ schedules: {
+ view: 'edr_passenger_app:schedules:view',
+ manage: 'edr_passenger_app:schedules:manage',
+ },
+ packages: {
+ view: 'edr_passenger_app:packages:view',
+ manage: 'edr_passenger_app:packages:manage',
+ },
+ inquiries: {
+ view: 'edr_passenger_app:inquiries:view',
+ manage: 'edr_passenger_app:inquiries:manage',
+ },
+ tariffRates: {
+ view: 'edr_passenger_app:tariff_rates:view',
+ manage: 'edr_passenger_app:tariff_rates:manage',
+ },
reports: {
view: 'edr_passenger_app:reports:view',
},
@@ -73,6 +152,7 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:agents:manage',
},
currencies: {
+ view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage',
},
notifications: {
@@ -126,9 +206,36 @@ export const ROLE_PERMISSION_PRESETS = {
],
finance: [
+ PASSENGER_PERMS.payments.view,
PASSENGER_PERMS.payments.viewAll,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.reports.view,
PASSENGER_PERMS.dashboard.view,
],
+
+ operationsManager: [
+ PASSENGER_PERMS.stations.view, PASSENGER_PERMS.stations.manage,
+ PASSENGER_PERMS.trains.view, PASSENGER_PERMS.trains.manage,
+ PASSENGER_PERMS.coaches.view, PASSENGER_PERMS.coaches.manage,
+ PASSENGER_PERMS.seats.view, PASSENGER_PERMS.seats.manage,
+ PASSENGER_PERMS.classes.view, PASSENGER_PERMS.classes.manage,
+ PASSENGER_PERMS.routes.view, PASSENGER_PERMS.routes.manage,
+ PASSENGER_PERMS.schedules.view, PASSENGER_PERMS.schedules.manage,
+ PASSENGER_PERMS.dashboard.view,
+ ],
+
+ marketingManager: [
+ PASSENGER_PERMS.packages.view, PASSENGER_PERMS.packages.manage,
+ PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.inquiries.manage,
+ PASSENGER_PERMS.dashboard.view,
+ ],
+
+ financeManager: [
+ PASSENGER_PERMS.tariffRates.view, PASSENGER_PERMS.tariffRates.manage,
+ PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.manage,
+ PASSENGER_PERMS.currencies.view, PASSENGER_PERMS.currencies.manage,
+ PASSENGER_PERMS.paymentMethods.view, PASSENGER_PERMS.paymentMethods.manage,
+ PASSENGER_PERMS.reports.view,
+ PASSENGER_PERMS.dashboard.view,
+ ],
} as const;
diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
index 8937497c6..4e6a71950 100644
--- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
@@ -9,14 +9,13 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api';
-import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() {
- const canManagePayments = usePermission(PERMS.payments.manage);
+ const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin);
- const canManage = canManagePayments || canManageAdmin;
+ const canManage = canManageMethods || canManageAdmin;
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -228,11 +227,11 @@ export default function PaymentMethodsPage() {
Payment Methods
Manage supported payment systems
-
+ {canManage && (
setCreateModalOpen(true)}>
Add Method
-
+ )}
{successMessage && (
diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
index d7fddfac9..2396ca8b7 100644
--- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
+++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx
@@ -70,33 +70,33 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Tourism',
items: [
- { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
- // { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
- { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
+ { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.packages.view },
+ // { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.packages.view },
+ { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.inquiries.view },
]
},
{
title: 'Master Data',
items: [
- { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
- { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
- { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
- { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
- { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
- { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
- { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
+ { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view },
+ { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view },
+ { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view },
+ { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view },
+ { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
+ { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
+ { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
]
},
{
title: 'Financial',
items: [
// { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
- { name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
+ { name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.tariffRates.view },
// { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
- { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
+ { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.view },
// { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
- { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
+ { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.paymentMethods.view },
// { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
]
},
diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
index 7731d784e..2a893f971 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
@@ -13,11 +13,69 @@ export const PERMS = {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
},
- payments: {
- view: 'edr_passenger_app:payments:view_all',
- refund: 'edr_passenger_app:payments:refund',
- manage: 'edr_passenger_app:payments:manage_methods',
+
+ // ── Master Data ────────────────────────────────────────────────
+ stations: {
+ view: 'edr_passenger_app:stations:view',
+ manage: 'edr_passenger_app:stations:manage',
},
+ trains: {
+ view: 'edr_passenger_app:trains:view',
+ manage: 'edr_passenger_app:trains:manage',
+ },
+ coaches: {
+ view: 'edr_passenger_app:coaches:view',
+ manage: 'edr_passenger_app:coaches:manage',
+ },
+ seats: {
+ view: 'edr_passenger_app:seats:view',
+ manage: 'edr_passenger_app:seats:manage',
+ },
+ classes: {
+ view: 'edr_passenger_app:classes:view',
+ manage: 'edr_passenger_app:classes:manage',
+ },
+ routes: {
+ view: 'edr_passenger_app:routes:view',
+ manage: 'edr_passenger_app:routes:manage',
+ },
+ schedules: {
+ view: 'edr_passenger_app:schedules:view',
+ manage: 'edr_passenger_app:schedules:manage',
+ },
+
+ // ── Tourism ────────────────────────────────────────────────────
+ packages: {
+ view: 'edr_passenger_app:packages:view',
+ manage: 'edr_passenger_app:packages:manage',
+ },
+ inquiries: {
+ view: 'edr_passenger_app:inquiries:view',
+ manage: 'edr_passenger_app:inquiries:manage',
+ },
+
+ // ── Finance ────────────────────────────────────────────────────
+ tariffRates: {
+ view: 'edr_passenger_app:tariff_rates:view',
+ manage: 'edr_passenger_app:tariff_rates:manage',
+ },
+ payments: {
+ view: 'edr_passenger_app:payments:view',
+ manage: 'edr_passenger_app:payments:manage',
+ // legacy aliases — still honoured by the backend guards
+ viewAll: 'edr_passenger_app:payments:view_all',
+ refund: 'edr_passenger_app:payments:refund',
+ manageMethods: 'edr_passenger_app:payments:manage_methods',
+ },
+ paymentMethods: {
+ view: 'edr_passenger_app:payment_methods:view',
+ manage: 'edr_passenger_app:payment_methods:manage',
+ },
+ currencies: {
+ view: 'edr_passenger_app:currencies:view',
+ manage: 'edr_passenger_app:currencies:manage',
+ },
+
reports: {
view: 'edr_passenger_app:reports:view',
},
@@ -32,9 +90,6 @@ export const PERMS = {
view: 'edr_passenger_app:agents:view',
manage: 'edr_passenger_app:agents:manage',
},
- currencies: {
- manage: 'edr_passenger_app:currencies:manage',
- },
notifications: {
send: 'edr_passenger_app:notifications:send',
},
From c8fe2a78f8dc48fdbd1190e133e8ad7e547e6dde Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Tue, 14 Jul 2026 20:18:11 +0300
Subject: [PATCH 15/67] Update schedules.controller.ts
---
.../src/modules/schedules/schedules.controller.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
index 5263f4a22..b11ac3098 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
@@ -80,7 +80,7 @@ export class SchedulesController {
}
@Patch('routes/fare-rules/:id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
@@ -96,7 +96,7 @@ export class SchedulesController {
}
@Post('routes/:routeId/fare-rules')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
From f243fdd4e3204c35712efb2d28db54de50979972 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Tue, 14 Jul 2026 20:51:40 +0300
Subject: [PATCH 16/67] Currency and converted amount for non ETB
---
.../src/modules/bookings/bookings.dto.ts | 2 +-
.../src/modules/bookings/bookings.service.ts | 47 ++++--
.../backoffice/src/app/bookings/page.tsx | 2 +-
.../src/app/booking/confirmation/page.tsx | 13 +-
.../portal/src/app/booking/detail/page.tsx | 31 +++-
.../portal/src/app/booking/lookup/page.tsx | 7 +-
.../portal/src/app/booking/payment/page.tsx | 6 +-
.../portal/src/app/booking/results/page.tsx | 8 +-
.../portal/src/app/booking/review/page.tsx | 155 ++++++++++--------
.../portal/src/app/booking/seats/page.tsx | 14 +-
.../portal/src/lib/generate-voucher.ts | 11 +-
11 files changed, 180 insertions(+), 116 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index d50f48592..4b355c1fe 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -145,7 +145,7 @@ export class CreateBookingDto {
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
- @ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
+ @ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' })
@IsOptional() @IsInt() reviewedTotalMinor?: number;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
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 7f09ffa49..4e4bb706f 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -837,16 +837,30 @@ export class BookingsService {
// Free children have no seatId and no seatFareMinor — exclude them from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
- const resolvedTotalMinor = dto.reviewedTotalMinor ??
- (allFaresProvided
- ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
- : fareCalculation.totalMinor);
- this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
-
- let displayTotalMinor = resolvedTotalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ // reviewedTotalMinor is now sent in display-currency minor units from the review page.
+ // When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
+ let resolvedTotalMinor: number;
+ let displayTotalMinor: number;
+ if (dto.reviewedTotalMinor != null) {
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
+ } else {
+ resolvedTotalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = dto.reviewedTotalMinor;
+ }
+ } else if (allFaresProvided) {
+ resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
+ : resolvedTotalMinor;
+ } else {
+ resolvedTotalMinor = fareCalculation.totalMinor;
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
+ : resolvedTotalMinor;
}
+ this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
const booking = await this.prisma.booking.create({
data: {
@@ -857,7 +871,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: resolvedTotalMinor,
+ totalMinor: resolvedTotalMinor / 100,
adultCount,
childCount,
displayCurrency,
@@ -1011,11 +1025,14 @@ export class BookingsService {
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
- if (dto.reviewedTotalMinor) {
- totalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
- : totalMinor;
+ if (dto.reviewedTotalMinor != null) {
+ if (displayCurrency !== Currency.ETB) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
+ } else {
+ totalMinor = dto.reviewedTotalMinor;
+ displayTotalMinor = dto.reviewedTotalMinor;
+ }
} else if (allRTFaresProvided && !dto.packageId) {
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
if (displayCurrency !== Currency.ETB) {
diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
index 4ab5cb9b7..1a80a2aef 100644
--- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
@@ -270,7 +270,7 @@ function BookingsPageContent() {
render: (booking: any) => (
{booking.paymentIntent?.status || 'PENDING'}
-
{formatCurrency(booking.totalMinor, booking.currency)}
+
{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}
),
},
diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
index f4e67656d..f0e221f48 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx
@@ -126,7 +126,10 @@ export default function ConfirmationPage() {
const settledAmountMinor = _booking?.payment?.amountMinor;
const settledCurrency = _booking?.payment?.currency;
const hasSettledAmount = settledAmountMinor != null && !!settledCurrency;
- const voucherCurrency = hasSettledAmount ? settledCurrency! : "ETB";
+ // Derive display currency from nationality (same logic as review/payment pages)
+ const nat = (searchCriteria?.nationality ?? '').toUpperCase();
+ const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency;
const createdAt = _booking?.createdAt || new Date().toISOString();
const status = _booking?.status || "CONFIRMED";
@@ -530,15 +533,15 @@ export default function ConfirmationPage() {
// The server-confirmed settled amount is authoritative — prefer it over
// any client-side session state, which can go stale (e.g. after a refresh).
if (_booking?.payment?.amountMinor != null) {
- return `${_booking.payment.currency || "ETB"} ${_booking.payment.amountMinor}`;
+ return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
}
if (reviewedTotalMinor != null)
- return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`;
+ return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
if (paidAmountMinor != null)
- return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`;
+ return `${paidCurrency || 'ETB'} ${(paidAmountMinor / 100).toFixed(2)}`;
if (_booking?.totalMinor != null)
return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`;
- return "ETB 0.00";
+ return 'ETB 0.00';
})()}
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 addaf685d..025c212bf 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
@@ -29,9 +29,11 @@ import {
} from "@/utils/manage-booking-return";
import QRCode from "qrcode.react";
-// Same convention as /booking/payment — payment methods are ETB-settled by default;
-// a method only needs a currency conversion when its own currency differs.
-const displayCurrency = "ETB" as const;
+// Derive display currency from the booking record's own displayCurrency field
+// (set at booking creation from the passenger's nationality). Falls back to ETB.
+function getBookingDisplayCurrency(booking: any): string {
+ return booking?.displayCurrency || 'ETB';
+}
const getIconForMethod = (methodType: string) => {
if (methodType.includes("CARD")) return CreditCard;
@@ -126,8 +128,10 @@ function BookingDetailContent() {
const selectedPaymentMethod =
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
+ const displayCurrency = getBookingDisplayCurrency(booking);
+
// Same conversion logic as /booking/payment: only hit the booking-amount-changer API
- // when the selected method actually settles in a different currency than ETB.
+ // when the selected method actually settles in a different currency than the booking's display currency.
const isConversionNeeded =
!!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
const amountCurrency = isConversionNeeded
@@ -152,7 +156,7 @@ function BookingDetailContent() {
? bookingAmountData != null
? bookingAmountData.amount
: null
- : (booking?.totalMinor ?? 0) / 100;
+ : (booking?.displayTotalMinor ?? booking?.totalMinor ?? 0) / 100;
const confirmedCurrency = isConversionNeeded
? bookingAmountData?.currency || amountCurrency
: displayCurrency;
@@ -399,6 +403,15 @@ function BookingDetailContent() {
}));
})();
+ // Scale per-passenger ETB fareMinor to the booking's display currency using the
+ // ratio of displayTotalMinor / totalMinor. Falls back to 1 (ETB) when not available.
+ const fareScaleFactor = (() => {
+ const etbTotal = booking?.totalMinor;
+ const displayTotal = booking?.displayTotalMinor;
+ if (!etbTotal || !displayTotal || etbTotal === displayTotal) return 1;
+ return displayTotal / etbTotal;
+ })();
+
// 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.
@@ -439,7 +452,7 @@ function BookingDetailContent() {
)}
- {formatFare(passenger.fareMinor ?? 0, displayCurrency)}
+ {formatFare(Math.round((passenger.fareMinor ?? 0) * fareScaleFactor), displayCurrency)}
{isRoundTripBooking && !isFreeChild && (
@@ -448,7 +461,7 @@ function BookingDetailContent() {
Outbound
{formatFare(
- passenger.outboundFareMinor ?? 0,
+ Math.round((passenger.outboundFareMinor ?? 0) * fareScaleFactor),
displayCurrency,
)}
@@ -457,7 +470,7 @@ function BookingDetailContent() {
Return
{formatFare(
- passenger.returnFareMinor ?? 0,
+ Math.round((passenger.returnFareMinor ?? 0) * fareScaleFactor),
displayCurrency,
)}
@@ -937,7 +950,7 @@ function BookingDetailContent() {
Total paid:{" "}
{booking?.payment?.amountMinor != null
- ? `${booking.payment.currency || "ETB"} ${booking.payment.amountMinor}`
+ ? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
{booking?.payment?.method && (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
index 3f371280f..123cd5710 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx
@@ -15,6 +15,8 @@ interface BookingListItem {
status: string;
totalMinor: number;
currency: string;
+ displayCurrency?: string | null;
+ displayTotalMinor?: number | null;
adultCount: number;
childCount: number;
bookingType: string;
@@ -200,7 +202,8 @@ export default function BookingLookupPage() {
{phoneResults.map((b) => {
const statusInfo = STATUS_LABELS[b.status] ?? { label: b.status, className: "bg-gray-100 text-gray-700" };
- const amountEtb = (b.totalMinor / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
+ const displayCurrency = b.displayCurrency || 'ETB';
+ const displayAmount = ((b.displayTotalMinor ?? b.totalMinor) / 100).toLocaleString("en-ET", { minimumFractionDigits: 2 });
return (
- {amountEtb} ETB
+ {displayAmount} {displayCurrency}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index 237f8086e..e56680d11 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -49,7 +49,9 @@ export default function PaymentPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
const isPackage = !!packageName;
- const displayCurrency = 'ETB' as const;
+ // Use the same display currency as the review page (derived from nationality)
+ const nat = (searchCriteria?.nationality ?? '').toUpperCase();
+ const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({
queryKey: ['paymentMethods', displayCurrency],
@@ -314,7 +316,7 @@ export default function PaymentPage() {
{fare !== undefined && (
{label} fare
- {displayCurrency} {(fare / 100).toFixed(2)}
+ {confirmedCurrency} {(fare / 100).toFixed(2)}
)}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index fa31d1a7c..e13d5dab6 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -23,7 +23,6 @@ import {
import { format } from "date-fns";
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
-import { useCurrencySymbol } from "@/lib/useCurrencies";
import { useState, useEffect } from "react";
export default function ResultsPage() {
@@ -97,7 +96,6 @@ export default function ResultsPage() {
const nat = (searchData.nationality ?? '').toUpperCase();
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
- const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
useEffect(() => {
if (searchParams.get("origin")) {
@@ -419,7 +417,7 @@ export default function ResultsPage() {
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
)
: 0;
- const coachCurrency = displayCurrencySymbol;
+ const coachCurrency = displayCurrencyCode;
const CoachIcon = getCoachIcon(coachType.coachTypeName);
const selectThisCoach = () =>
@@ -540,7 +538,7 @@ export default function ResultsPage() {
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
-
+
{coachCurrency}
@@ -616,7 +614,7 @@ export default function ResultsPage() {
// Calculate lowest fare and display currency from coach types / faresByClass.
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
let lowestFare = null;
- const displayCurrency = displayCurrencySymbol;
+ const displayCurrency = displayCurrencyCode;
if (schedule.coachTypes?.length) {
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
const allFares = allClasses
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 237d689f1..f36324bd1 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
@@ -7,10 +7,9 @@ import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { format } from 'date-fns';
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { ChevronLeft } from 'lucide-react';
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
-import { useCurrencySymbol } from '@/lib/useCurrencies';
// Helper function to decode JWT token and extract passengerId
function getPassengerIdFromToken(token: string): string | null {
@@ -60,7 +59,6 @@ export default function ReviewPage() {
// Derive display currency from nationality so fares show in the passenger's home currency.
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
- const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
useEffect(() => {
if (!seatHold?.expiresAt) return;
@@ -152,7 +150,8 @@ export default function ReviewPage() {
}, [selectedSchedule?.id, outboundSchedule?.id, inboundSchedule?.id, passengers, isRoundTrip]);
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
- const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
+
+const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
const pkgChildFare = pkgAdultFare;
@@ -166,7 +165,15 @@ export default function ReviewPage() {
return childIndex < adultPassengerCount;
};
- const getPassengerSeatFare = (p: any): number | null => {
+ // Returns the fare for a passenger in the display currency.
+ // 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 (!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;
@@ -445,13 +452,14 @@ export default function ReviewPage() {
const isFreeChild = isPackageBooking
? (isChildPassenger && (i - adultPassengerCount) < adultPassengerCount)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
- const seatFare = getPassengerSeatFare(p);
+ const seatFare = getPassengerSeatFare(p, i);
const pkgFallback = isChildPassenger ? pkgChildFare : pkgAdultFare;
const fareMinor = isPackageBooking
? (isFreeChild ? 0 : (seatFare ?? pkgFallback))
: (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
- const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined;
- const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined;
+ const halfFare = seatFare != null ? Math.round(seatFare / 2) : undefined;
+ const outboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).outboundSeatFareMinor ?? undefined) : undefined;
+ const inboundFareMinor = isRoundTrip ? (halfFare ?? (p as any).inboundSeatFareMinor ?? undefined) : undefined;
return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor };
});
setReviewedTotal(computedTotal, passengerFares);
@@ -482,55 +490,60 @@ export default function ReviewPage() {
}
}, [isRoundTrip, selectedSchedule, outboundSchedule, inboundSchedule, passengers.length, createBookingMutation.isPending, createBookingMutation.isSuccess, router]);
- const fetchFareBreakdown = useCallback(async (scheduleId: string, originStationId: string, destinationStationId: string) => {
- // Package bookings use the stored tier price — no fare calculation needed
- if (isPackageBooking) return;
-
- try {
- const seatClasses: any[] = await apiClient.get('/seat-classes');
- const scheduleSeatClassName = isRoundTrip
- ? (outboundSchedule as any)?.seatClassName
- : (selectedSchedule as any)?.seatClassName;
- const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
- if (!fallbackSeatClassId) return;
-
- const resolveSeatClassId = (p: any): string => {
- const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
- if (!bedPosition) return fallbackSeatClassId;
- const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
- return match?.id || fallbackSeatClassId;
- };
-
- const passengersParam = JSON.stringify(
- passengers.map(p => ({
- passengerName: p.name,
- dateOfBirth: p.dateOfBirth,
- seatClassId: resolveSeatClassId(p),
- nationality: p.nationality,
- }))
- );
-
- const params = new URLSearchParams({
- scheduleId,
- originStationId,
- destinationStationId,
- passengers: passengersParam,
- displayCurrency: displayCurrencyCode,
- ...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
- });
-
- const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
- setFareBreakdown(result);
- } catch (err) {
- }
- }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
+ const fareBreakdownFetchedRef = useRef(false);
+ const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
useEffect(() => {
+ if (fareBreakdownFetchedRef.current) return;
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
- const scheduleId = isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id;
if (!scheduleId) return;
- fetchFareBreakdown(scheduleId, searchCriteria.originStationId, searchCriteria.destinationStationId);
- }, [fetchFareBreakdown, isRoundTrip, outboundSchedule?.id, selectedSchedule?.id, searchCriteria?.originStationId, searchCriteria?.destinationStationId]);
+ if (isPackageBooking) return;
+
+ fareBreakdownFetchedRef.current = true;
+
+ (async () => {
+ try {
+ const seatClasses: any[] = await apiClient.get('/seat-classes');
+ const scheduleSeatClassName = isRoundTrip
+ ? (outboundSchedule as any)?.seatClassName
+ : (selectedSchedule as any)?.seatClassName;
+ const fallbackSeatClassId = seatClasses.find((sc: any) => sc.name === scheduleSeatClassName)?.id || seatClasses[0]?.id;
+ if (!fallbackSeatClassId) return;
+
+ const resolveSeatClassId = (p: any): string => {
+ const bedPosition: string | undefined = isRoundTrip ? (p as any).outboundBedPosition : (p as any).bedPosition;
+ if (!bedPosition) return fallbackSeatClassId;
+ const match = seatClasses.find((sc: any) => sc.name?.toLowerCase().includes(bedPosition));
+ return match?.id || fallbackSeatClassId;
+ };
+
+ const passengersParam = JSON.stringify(
+ passengers.map(p => ({
+ passengerName: p.name,
+ dateOfBirth: p.dateOfBirth,
+ seatClassId: resolveSeatClassId(p),
+ nationality: p.nationality,
+ }))
+ );
+
+ const params = new URLSearchParams({
+ scheduleId,
+ originStationId: searchCriteria.originStationId,
+ destinationStationId: searchCriteria.destinationStationId,
+ passengers: passengersParam,
+ displayCurrency: displayCurrencyCode,
+ ...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
+ });
+
+ const result: any = await apiClient.get(`/search/fare-breakdown?${params}`);
+ setFareBreakdown(result);
+ } catch (err) {
+ }
+ })();
+ // Intentionally re-runs until store is hydrated (scheduleId/originStationId become
+ // available), then the ref guard ensures it only fetches once.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchCriteria?.originStationId, searchCriteria?.destinationStationId, scheduleId]);
// For package bookings, passengers are initialized without dateOfBirth so isChild() is
// unreliable. Use the stored adultCount from searchCriteria to determine category by index.
@@ -539,7 +552,7 @@ export default function ReviewPage() {
const isChild_ = isPackageChild(i);
const isFreeChild = isChild_ && (i - adultPassengerCount) < adultPassengerCount;
if (isFreeChild) return sum;
- const seatFare = getPassengerSeatFare(p);
+ const seatFare = getPassengerSeatFare(p, i);
const pkgFallback = isChild_ ? pkgChildFare : pkgAdultFare;
return sum + (seatFare ?? pkgFallback);
}, 0)
@@ -548,8 +561,9 @@ export default function ReviewPage() {
const line = fareBreakdown?.passengers?.[i];
const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i));
if (isFreeChild) return sum;
- const seatFare = getPassengerSeatFare(p);
- return sum + (seatFare ?? line?.fareMinor ?? 0);
+ const seatFare = getPassengerSeatFare(p, i);
+ const displayFare = line?.displayFareMinor ?? line?.fareMinor;
+ return sum + (seatFare ?? displayFare ?? 0);
}, 0);
// Keep computedTotal in sync so handleConfirm can persist it to the store
@@ -568,17 +582,26 @@ export default function ReviewPage() {
? isPkgFreeChild(i)
: (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i)));
- // Per-leg fares for round trips
+ // Per-leg fares for round trips — use converted amounts from fareBreakdown when available
+ const displayFare = line?.displayFareMinor ?? line?.fareMinor;
const outboundFare: number | null = isRoundTrip
- ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null))
+ ? (isPackageBooking
+ ? (packageTierPriceMinor ?? null)
+ : (displayFare != null
+ ? Math.round(displayFare / 2)
+ : ((p as any).outboundSeatFareMinor ?? null)))
: null;
const inboundFare: number | null = isRoundTrip
- ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null))
+ ? (isPackageBooking
+ ? (packageTierPriceMinor ?? null)
+ : (displayFare != null
+ ? Math.round(displayFare / 2)
+ : ((p as any).inboundSeatFareMinor ?? null)))
: null;
- const seatFare = getPassengerSeatFare(p);
+ const seatFare = getPassengerSeatFare(p, i);
const passengerTotal = isPackageBooking
? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare)))
- : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0));
+ : (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0));
return (
@@ -594,7 +617,7 @@ export default function ReviewPage() {
)}
- {formatFare(passengerTotal, displayCurrencySymbol)}
+ {formatFare(passengerTotal, displayCurrencyCode)}
{/* Round-trip: show outbound + inbound breakdown */}
@@ -602,11 +625,11 @@ export default function ReviewPage() {
↗ Outbound
- {outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}
+ {outboundFare != null ? formatFare(outboundFare, displayCurrencyCode) : '—'}
↙ Return
- {inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}
+ {inboundFare != null ? formatFare(inboundFare, displayCurrencyCode) : '—'}
)}
@@ -615,7 +638,7 @@ export default function ReviewPage() {
})}
Total
- {formatFare(total, displayCurrencySymbol)}
+ {formatFare(total, displayCurrencyCode)}
{/* Action buttons — visible only in desktop sidebar */}
@@ -963,7 +986,7 @@ export default function ReviewPage() {
Total
- {formatFare(total, displayCurrencySymbol)}
+ {formatFare(total, displayCurrencyCode)}
{createBookingMutation.isError && (
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 1463b238a..b0b6e2350 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
@@ -415,7 +415,7 @@ export default function SeatsPage() {
if (!coachTypeId) return null;
const types = (currentSchedule as any)?.coachTypes || [];
const match = types.find((ct: any) => ct.coachTypeId === coachTypeId || ct.coachId === coachTypeId);
- const fares = (match?.classes || []).map((c: any) => c.baseFareMinor).filter((f: number) => f > 0);
+ const fares = (match?.classes || []).map((c: any) => c.displayAmountMinor ?? c.baseFareMinor).filter((f: number) => f > 0);
return fares.length ? Math.min(...fares) : null;
};
@@ -437,10 +437,10 @@ export default function SeatsPage() {
const match = currentCoachTypeClasses.find((c: any) =>
c.name?.toLowerCase().includes(seat.bedPosition),
);
- if (match) return match.baseFareMinor;
+ if (match) return match.displayAmountMinor ?? match.baseFareMinor;
}
const regular = currentCoachTypeClasses.find((c: any) => /regular/i.test(c.name || ""));
- return (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
+ return (regular || currentCoachTypeClasses[0])?.displayAmountMinor ?? (regular || currentCoachTypeClasses[0])?.baseFareMinor ?? null;
},
[currentCoachTypeClasses],
);
@@ -573,7 +573,7 @@ export default function SeatsPage() {
setModalState({
isOpen: true,
title: "Fare Will Change",
- message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
+ message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult (currently ${currentSchedule?.displayCurrency || 'ETB'} ${(currentFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)}). Continue?`,
type: "warning",
showCancel: true,
confirmText: "Switch Coach",
@@ -588,7 +588,7 @@ export default function SeatsPage() {
isOpen: true,
title: "Switch Coach Type",
message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${
- newFare != null ? ` Fare: ETB ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
+ newFare != null ? ` Fare: ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * (isRoundTrip ? 2 : 1)).toFixed(2)} per adult.` : " This will have a fare change."
}`,
type: "info",
showCancel: true,
@@ -926,7 +926,7 @@ export default function SeatsPage() {
setModalState({
isOpen: true,
title: "Fare Will Change",
- message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (ETB ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
+ message: `${positionLabel} ${seatLabel} costs ${currentSchedule?.displayCurrency || 'ETB'} ${(newFare / 100 * legMultiplier).toFixed(2)}, different from ${referenceLabel} (${currentSchedule?.displayCurrency || 'ETB'} ${(referenceFare / 100 * legMultiplier).toFixed(2)}). Continue with this selection?`,
type: "warning",
showCancel: true,
confirmText: "Continue",
@@ -1981,7 +1981,7 @@ export default function SeatsPage() {
{assignedSeat && seatFare != null && (
- ETB {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
+ {currentSchedule?.displayCurrency || 'ETB'} {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
)}
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
index 47ee3fdcc..18c99d52f 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -452,7 +452,9 @@ interface VoucherData {
schedule: VoucherSchedule;
returnSchedule?: VoucherSchedule | null;
totalMinor: number;
- currency: string;
+ displayTotalMinor?: number;
+ currency?: string;
+ displayCurrency?: string;
bookingType: string;
createdAt: string;
// One ticket per passenger per leg (round trips have a separate ticket/barcode for the
@@ -473,7 +475,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise =>
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
- const voucherCurrency = useSettledAmount ? settledCurrency! : booking.currency;
+ // Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
+ const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
+ // Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
+ const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
@@ -519,7 +524,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise =>
seatNumber: isRoundTrip ? undefined : p.outboundSeat?.number,
outboundSeatNumber: isRoundTrip ? p.outboundSeat?.number : undefined,
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
- fareMinor: useSettledAmount ? settledAmountMinor! : booking.totalMinor,
+ fareMinor: voucherFareMinor,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,
From 7d64c919152acc25a66979396ea976c18ae735b2 Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Tue, 14 Jul 2026 21:09:49 +0300
Subject: [PATCH 17/67] feat: ( auth ) restore sign in/register UI in nav bar,
sidebar, and auth-check
---
.../src/app/booking/auth-check/page.tsx | 4 +---
.../portal/src/components/AppSidebar.tsx | 6 ++----
.../portal/src/components/BottomTabBar.tsx | 19 +++++++++----------
3 files changed, 12 insertions(+), 17 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 8db5e35ad..d9c7f92a3 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,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useBookingStore } from '@/lib/booking-store';
-import { UserPlus, ChevronLeft } from 'lucide-react';
+import { UserPlus, LogIn, ChevronLeft } from 'lucide-react';
function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) {
const [visible, setVisible] = useState(false);
@@ -95,7 +95,6 @@ export default function AuthCheckPage() {
- {/* TODO: re-enable once auth is integrated
- */}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
index 81a455401..0acb5d9ea 100644
--- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
+++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
@@ -192,9 +192,7 @@ export default function AppSidebar() {
)}
) : (
- // TODO: Sign in / Register temporarily disabled — re-enable later.
- null
- /*
)}
diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
index 9a1d71f15..ee4cdccad 100644
--- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
+++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
@@ -1,10 +1,9 @@
'use client';
-// NOTE: User icon + useAuthStore are unused while the Sign in / Account tab is
-// temporarily disabled below. Re-add them when that tab is restored.
-import { Home, Phone, Ticket } from 'lucide-react';
+import { Home, Phone, Ticket, User } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
+import { useAuthStore } from '@/lib/auth-store';
// 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),
@@ -22,6 +21,7 @@ const LINEAR_FLOW_PREFIXES = [
export default function BottomTabBar() {
const pathname = usePathname() ?? '';
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
if (isInLinearFlow) return null;
@@ -30,13 +30,12 @@ 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') },
- // TODO: Sign in / Account tab temporarily disabled — re-enable later.
- // {
- // href: isAuthenticated ? '/profile' : '/login',
- // label: isAuthenticated ? 'Account' : 'Sign in',
- // icon: User,
- // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
- // },
+ {
+ 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 660c611a1c461bfd4b93ad732d12c4aeb9a3343e Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 21:22:04 +0300
Subject: [PATCH 18/67] Fix route datetime on lookup page
---
.../src/modules/bookings/bookings.service.ts | 42 ++++++++++---------
1 file changed, 22 insertions(+), 20 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 2469b24bb..45091a604 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1745,30 +1745,32 @@ export class BookingsService {
);
}
- // Resolves the passenger's actual boarding/alighting stations for one leg from
- // originStationId/destinationStationId (set when the booking covers only part of a
- // longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
- // the schedule's stopTimes, falling back to the schedule's own full-route endpoints
- // when there's no segment override (older records, or a booking that covers the
- // whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
- // already applied to SMS/email; this brings the booking API (voucher, detail page,
- // confirmation) to the same behavior instead of always showing the train's full route.
+ // Resolves the passenger's actual boarding/alighting stations AND times for one leg
+ // from originStationId/destinationStationId (set when the booking covers only part of
+ // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
+ // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/
+ // times when there's no segment override (older records, or a booking that covers the
+ // whole run). Station resolution mirrors notifications.service.ts's
+ // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt
+ // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt
+ // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page,
+ // confirmation) to the same behavior search results already have, instead of always
+ // showing the train's full-route span.
private resolveSegmentStations(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
- ): { origin: any; destination: any } {
+ ): { origin: any; destination: any; departureAt: any; arrivalAt: any } {
const stopTimes: any[] = schedule?.stopTimes ?? [];
- const findStation = (stationId: string | null | undefined, fallback: any) => {
- if (stationId && stopTimes.length > 0) {
- const stop = stopTimes.find((st: any) => st.stationId === stationId);
- if (stop?.station) return stop.station;
- }
- return fallback ?? null;
- };
+ const findStop = (stationId: string | null | undefined) =>
+ stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined;
+ const originStop = findStop(originStationId);
+ const destStop = findStop(destinationStationId);
return {
- origin: findStation(originStationId, schedule?.originStation),
- destination: findStation(destinationStationId, schedule?.destinationStation),
+ origin: originStop?.station ?? schedule?.originStation ?? null,
+ destination: destStop?.station ?? schedule?.destinationStation ?? null,
+ departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null,
+ arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null,
};
}
@@ -1878,7 +1880,7 @@ export class BookingsService {
trainName: (booking as any).schedule.train.name,
origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
- departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
+ departureAt: outboundSegment.departureAt, arrivalAt: outboundSegment.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
? {
@@ -1887,7 +1889,7 @@ export class BookingsService {
trainName: (booking as any).returnSchedule.train.name,
origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
- departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
+ departureAt: returnSegment!.departureAt, arrivalAt: returnSegment!.arrivalAt,
}
: null,
passengers: (booking as any).seats?.map((bs: any) => ({
From 43aecf8376963e57c5b4c3dfdfb524662429643c Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Tue, 14 Jul 2026 21:25:27 +0300
Subject: [PATCH 19/67] Update page.tsx
---
apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx | 1 +
1 file changed, 1 insertion(+)
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 7a39b1b97..84612cdbc 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
@@ -1927,6 +1927,7 @@ export default function SeatsPage() {
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
+ const seatFare = assignedSeat ? getSeatFare(assignedSeat) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
From 89b99b0a0845e5adac6bd2c92db319bcf9c7141a Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 21:57:34 +0300
Subject: [PATCH 20/67] Fix seat label
---
.../src/modules/bookings/bookings.service.ts | 40 ++++++++++++-------
1 file changed, 26 insertions(+), 14 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 45091a604..70fef6b08 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1892,20 +1892,32 @@ export class BookingsService {
departureAt: returnSegment!.departureAt, arrivalAt: returnSegment!.arrivalAt,
}
: null,
- passengers: (booking as any).seats?.map((bs: any) => ({
- fullName: bs.passengerName,
- category: bs.passengerCategory,
- leg: bs.leg ?? 1,
- fareMinor: bs.fareMinor,
- verifaydaVerified: bs.verifaydaVerified,
- seat: {
- id: bs.seat.id,
- number: bs.seat.seatNumber,
- coach: bs.seat.coach.number,
- coachId: bs.seat.coach.id,
- seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
- },
- })),
+ passengers: (booking as any).seats?.map((bs: any) => {
+ // A coach type can have several seat classes (e.g. a VIP Bed coach has separate
+ // Upper/Lower classes) — seatClasses[0] is whichever was seeded first, so it always
+ // showed the SAME class for every seat in the coach regardless of that seat's own
+ // bed position. Match against the seat's actual bedPosition instead (Seat.bedPosition
+ // is lowercase, SeatClass.bedPosition is uppercase — compare case-insensitively).
+ // Falls back to [0] for non-bed seats (bedPosition is null, single class per coach).
+ const classes = bs.seat.coach.coachType?.seatClasses ?? [];
+ const matchedClass = bs.seat.bedPosition
+ ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat.bedPosition.toLowerCase())
+ : null;
+ return {
+ fullName: bs.passengerName,
+ category: bs.passengerCategory,
+ leg: bs.leg ?? 1,
+ fareMinor: bs.fareMinor,
+ verifaydaVerified: bs.verifaydaVerified,
+ seat: {
+ id: bs.seat.id,
+ number: bs.seat.seatNumber,
+ coach: bs.seat.coach.number,
+ coachId: bs.seat.coach.id,
+ seatClass: (matchedClass ?? classes[0])?.name ?? null,
+ },
+ };
+ }),
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
From d92d1cdaa42455ce0164abc1289e54eef05a635a Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Tue, 14 Jul 2026 22:43:57 +0300
Subject: [PATCH 21/67] Segment price override enabled
---
.../app/tariff-rates/SegmentOverridesTab.tsx | 289 ++++++++++++++++++
.../backoffice/src/app/tariff-rates/hooks.ts | 31 +-
.../backoffice/src/app/tariff-rates/page.tsx | 8 +-
.../backoffice/src/app/tariff-rates/types.ts | 18 +-
.../portal/src/app/booking/seats/page.tsx | 3 +-
5 files changed, 345 insertions(+), 4 deletions(-)
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
new file mode 100644
index 000000000..7dcb445c4
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
@@ -0,0 +1,289 @@
+'use client';
+
+import { useState } from 'react';
+import { Plus, Edit, Trash2 } from 'lucide-react';
+import DataTable from '@/components/ui/DataTable';
+import ActionButton from '@/components/ui/ActionButton';
+import ConfirmDialog from '@/components/ui/ConfirmDialog';
+import Modal from '@/components/ui/Modal';
+import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes } from './hooks';
+import type { Route, SegmentFareRule, SeatClass } from './types';
+import { useQuery } from '@tanstack/react-query';
+import { apiClient } from '@/lib/api-client';
+
+interface RouteStop { sequence: number; station?: { name: string; code: string } }
+
+function useRouteStops(routeId: string | null) {
+ const { data } = useQuery({
+ queryKey: ['route-stops', routeId],
+ queryFn: () => apiClient.get(`/routes/${routeId}/stops`),
+ enabled: !!routeId,
+ });
+ return (Array.isArray(data) ? data : (data as any)?.items ?? []) as RouteStop[];
+}
+
+function useExchangeRates() {
+ const { data } = useQuery({
+ queryKey: ['exchange-rates'],
+ queryFn: () => apiClient.get('/currencies'),
+ });
+ const rates: any[] = Array.isArray(data) ? data : (data as any)?.items ?? [];
+ // Build ETB→X lookup: rate value
+ const rateMap: Record = {};
+ for (const r of rates) {
+ if (r.fromCurrency === 'ETB') rateMap[r.toCurrency] = r.rate;
+ }
+ return rateMap;
+}
+
+function formatFixed(amountMinor: number, currency = 'ETB', rateMap: Record = {}) {
+ const etb = (amountMinor / 100).toFixed(2);
+ if (currency === 'ETB') return `ETB ${etb}`;
+ const rate = rateMap[currency];
+ if (!rate) return `ETB ${etb}`;
+ const converted = ((amountMinor / 100) * rate).toFixed(2);
+ return `ETB ${etb} ≈ ${currency} ${converted}`;
+}
+
+interface Props { routes: Route[] }
+
+type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
+
+export default function SegmentOverridesTab({ routes }: Props) {
+ const [selectedRouteId, setSelectedRouteId] = useState(null);
+ const [form, setForm] = useState({ isOpen: false, rule: null, error: null });
+ const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
+
+ const { segmentFares, isLoading } = useSegmentFareRules(selectedRouteId);
+ const { create, update, remove } = useSegmentFareMutations(selectedRouteId);
+ const { allClasses } = useSeatClasses();
+ const stops = useRouteStops(selectedRouteId);
+ const rateMap = useExchangeRates();
+
+ const stopLabel = (seq: number) => {
+ const s = stops.find(st => st.sequence === seq);
+ return s?.station ? `${s.station.name} (${s.station.code})` : `Stop ${seq}`;
+ };
+
+ const handleFormSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setForm(prev => ({ ...prev, error: null }));
+ const fd = new FormData(e.currentTarget);
+ const baseFareMinor = Math.round(Number(fd.get('baseFareMinor')) * 100);
+ try {
+ if (form.rule) {
+ await update.mutateAsync({ id: form.rule.id, baseFareMinor });
+ } else {
+ await create.mutateAsync({
+ routeId: selectedRouteId,
+ originStopSequence: Number(fd.get('originStopSequence')),
+ destinationStopSequence: Number(fd.get('destinationStopSequence')),
+ seatClassId: fd.get('seatClassId') as string,
+ nationality: (fd.get('nationality') as string) || null,
+ baseFareMinor,
+ validFrom: new Date().toISOString(),
+ });
+ }
+ setForm({ isOpen: false, rule: null, error: null });
+ } catch (err: any) {
+ setForm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Failed to save' }));
+ }
+ };
+
+ const handleDelete = async () => {
+ try {
+ await remove.mutateAsync(deleteConfirm.id!);
+ setDeleteConfirm({ isOpen: false, id: null, name: '' });
+ } catch (err: any) {
+ setDeleteConfirm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Delete failed' }));
+ }
+ };
+
+ const columns = [
+ {
+ key: 'segment', label: 'Segment',
+ render: (r: SegmentFareRule) => (
+
+ {stopLabel(r.originStopSequence)} → {stopLabel(r.destinationStopSequence)}
+
+ ),
+ },
+ {
+ key: 'seatClass', label: 'Seat Class',
+ render: (r: SegmentFareRule) => {r.seatClass?.name ?? r.seatClassId} ,
+ },
+ {
+ key: 'nationality', label: 'Nationality',
+ render: (r: SegmentFareRule) => {r.nationality ?? 'All'} ,
+ },
+ {
+ key: 'baseFareMinor', label: 'Fixed Price',
+ render: (r: SegmentFareRule) => (
+ {formatFixed(r.baseFareMinor, 'ETB', rateMap)}
+ ),
+ },
+ {
+ key: 'converted', label: 'Converted',
+ render: (r: SegmentFareRule) => (
+
+ {Object.entries(rateMap).map(([cur, rate]) => (
+
{cur} {((r.baseFareMinor / 100) * rate).toFixed(2)}
+ ))}
+
+ ),
+ },
+ {
+ key: 'validFrom', label: 'Valid From',
+ render: (r: SegmentFareRule) => {new Date(r.validFrom).toLocaleDateString()} ,
+ },
+ {
+ key: 'validUntil', label: 'Valid Until',
+ render: (r: SegmentFareRule) => {r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'} ,
+ },
+ ];
+
+ const isAddMode = form.isOpen && !form.rule;
+ const isPending = create.isPending || update.isPending;
+
+ return (
+ <>
+
+ Segment overrides set a fixed total price for a specific origin→destination stop pair, bypassing per-km calculation.
+ Precedence: Segment Override → Route Override → Seat Class Tariff.
+
+
+
+
setSelectedRouteId(e.target.value || null)}
+ >
+ Select route
+ {routes.map(r => (
+ {r.name}{r.code ? ` (${r.code})` : ''}
+ ))}
+
+
+
setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}>
+ Add Segment Override
+
+
+
+ {!selectedRouteId ? (
+ Select a route above to view its segment overrides.
+ ) : (
+ setForm({ isOpen: true, rule: r, error: null }) },
+ { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) },
+ ]}
+ loading={isLoading}
+ emptyMessage="No segment overrides for this route."
+ />
+ )}
+
+ setForm({ isOpen: false, rule: null, error: null })}
+ title={isAddMode ? 'Add Segment Override' : 'Edit Segment Override'}
+ size="md"
+ >
+
+
+
+ setDeleteConfirm({ isOpen: false, id: null, name: '' })}
+ onConfirm={handleDelete}
+ title="Delete Segment Override"
+ message={`Delete the segment override for "${deleteConfirm.name}"? The route override or global tariff will apply instead.`}
+ confirmText="Delete"
+ isDanger
+ isLoading={remove.isPending}
+ error={deleteConfirm.error}
+ warning="Removing this override means bookings on this segment will fall back to the route override or global tariff rate."
+ />
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
index cd6f6b5b4..5dad6f4be 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
-import type { SeatClass, CoachType, Route, RouteFareRule, BaggageAllowance } from './types';
+import type { SeatClass, CoachType, Route, RouteFareRule, SegmentFareRule, BaggageAllowance } from './types';
function toArray(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
@@ -32,6 +32,35 @@ export function useRoutes() {
return { routes: toArray(data) };
}
+export function useSegmentFareRules(routeId: string | null) {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['segment-fare-rules', routeId],
+ queryFn: () => apiClient.get(`/schedules/routes/${routeId}/segment-fares`),
+ enabled: !!routeId,
+ });
+ return { segmentFares: toArray(data), isLoading, refetch };
+}
+
+export function useSegmentFareMutations(routeId: string | null) {
+ const queryClient = useQueryClient();
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ['segment-fare-rules', routeId] });
+
+ const create = useMutation({
+ mutationFn: (data: any) => apiClient.post('/schedules/segment-fares', data),
+ onSuccess: invalidate,
+ });
+ const update = useMutation({
+ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/segment-fares/${id}`, data),
+ onSuccess: invalidate,
+ });
+ const remove = useMutation({
+ mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove };
+}
+
export function useRouteFareRules(routeId: string | null) {
const { data, isLoading, refetch } = useQuery({
queryKey: ['route-fare-rules', routeId],
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index aef643b06..7239809f0 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -5,6 +5,7 @@ import { Plus } from 'lucide-react';
import ActionButton from '@/components/ui/ActionButton';
import TariffTab from './TariffTab';
import OverridesTab from './OverridesTab';
+import SegmentOverridesTab from './SegmentOverridesTab';
import BaggageTab from './BaggageTab';
import RateModal from './RateModal';
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
@@ -56,6 +57,7 @@ export default function TariffRatesPage() {
const tabs: { key: TabType; label: string }[] = [
{ key: 'tariff', label: 'Seat Class Tariffs' },
{ key: 'overrides', label: 'Route Overrides' },
+ { key: 'segment-overrides', label: 'Segment Overrides' },
{ key: 'baggage', label: 'Excess Luggage Rates' },
];
@@ -68,7 +70,7 @@ export default function TariffRatesPage() {
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
- {tab !== 'overrides' && (
+ {tab !== 'overrides' && tab !== 'segment-overrides' && (
{
if (tab === 'baggage') {
setShowBaggageModal(true);
@@ -113,6 +115,10 @@ export default function TariffRatesPage() {
)}
+ {tab === 'segment-overrides' && (
+
+ )}
+
{tab === 'baggage' && (
s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
+ const seatFare = assignedSeat ? getSeatFare(assignedSeat) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
From 7dc130d0ad208f55a3cbe62b0b019b57a0d63e2c Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 22:51:26 +0300
Subject: [PATCH 22/67] Show seat left on booking result
---
.../src/modules/search/search.service.ts | 33 +++++--
.../portal/src/app/booking/results/page.tsx | 89 ++++++++++++++++---
.../portal/src/types/index.ts | 1 +
3 files changed, 102 insertions(+), 21 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 7cbc6d881..9f9fb7bdc 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -398,10 +398,24 @@ export class SearchService {
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
]);
- // Compute per-class availability using the pre-computed free seat set
+ // Compute per-class availability using the pre-computed free seat set. A coach type
+ // has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND
+ // "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own
+ // nationality first, otherwise a name-based `.find()` across both tiers would credit
+ // all availability to whichever tier happens to come first in the query result,
+ // leaving the other tier's class permanently at 0 ("Fully booked") even when seats
+ // are actually free. Matched via the class's own bedPosition field (case-insensitive:
+ // Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a
+ // name substring, since that's an exact, unambiguous signal.
+ const nationalityUpper = (nationality ?? '').toUpperCase();
+ const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
+ ? 'LOCAL' : 'INTERNATIONAL';
+
const availabilityByClass: Record = {};
for (const assignment of schedule.coachAssignments) {
- const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
+ const seatClasses = (assignment.coach.coachType?.seatClasses ?? []).filter(
+ (sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
+ );
const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);
if (isBedCoach) {
@@ -412,8 +426,8 @@ export class SearchService {
if (freeSeats.has(seat.id)) count++;
}
if (count > 0) {
- const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
- if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count;
+ const matchingClass = seatClasses.find((sc: any) => sc.bedPosition?.toLowerCase() === bedPosition);
+ if (matchingClass) availabilityByClass[matchingClass.name] = (availabilityByClass[matchingClass.name] ?? 0) + count;
}
}
} else {
@@ -422,11 +436,12 @@ export class SearchService {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
if (freeSeats.has(seat.id)) available++;
}
- for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
+ const names = seatClasses.length > 0 ? seatClasses.map((sc: any) => sc.name) : ['Standard'];
+ for (const name of names) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
}
}
- const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality);
+ const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality, availabilityByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -748,12 +763,13 @@ export class SearchService {
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
nationality?: string,
+ availabilityByClass: Record = {},
): Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
- classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
+ classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number }>;
}> {
const coachTypeMap = new Map<
string,
@@ -795,9 +811,10 @@ export class SearchService {
baseFareMinor: fareInfo.baseFareMinor,
displayCurrency: fareInfo.displayCurrency,
displayAmountMinor: fareInfo.displayAmountMinor,
+ available: availabilityByClass[className] ?? 0,
};
})
- .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
+ .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index e13d5dab6..45331e761 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -25,6 +25,15 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
import { useState, useEffect } from "react";
+// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
+// modal, so both pick the same icon for a given coach type name.
+const getCoachIcon = (typeName: string) => {
+ const lower = typeName.toLowerCase();
+ if (lower.includes("soft") || lower.includes("vip")) return Star;
+ if (lower.includes("bed")) return Bed;
+ return Armchair;
+};
+
export default function ResultsPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -363,13 +372,6 @@ export default function ResultsPage() {
(ct: any) => ct.coachTypeCode !== "DPC",
);
- const getCoachIcon = (typeName: string) => {
- const lower = typeName.toLowerCase();
- if (lower.includes("soft") || lower.includes("vip")) return Star;
- if (lower.includes("bed")) return Bed;
- return Armchair;
- };
-
return (
<>
Class Options
-
- {coachType.classes.length} available
-
+ {coachType.classes.some((c: any) => c.available != null) && (
+
+ {coachType.classes.reduce((sum: number, c: any) => sum + (c.available ?? 0), 0)} seats left
+
+ )}
{coachType.classes.map(
@@ -530,9 +534,26 @@ export default function ResultsPage() {
>
-
- {cls.name}
-
+
+
+ {cls.name}
+
+ {cls.available != null && (
+
+ {cls.available === 0
+ ? "Fully booked"
+ : `${cls.available} seat${cls.available === 1 ? "" : "s"} left`}
+
+ )}
+
@@ -774,6 +795,48 @@ export default function ResultsPage() {
+
+ {schedule.coachTypes && schedule.coachTypes.length > 0 && (
+
+ {schedule.coachTypes
+ .filter((ct: any) => ct.coachTypeCode !== "DPC")
+ .map((ct: any, idx: number) => {
+ const CoachIcon = getCoachIcon(ct.coachTypeName);
+ // Only classes that actually reported a count contribute — if none of
+ // them did (API didn't return `available` for this coach type), there's
+ // nothing honest to show, so the count is omitted rather than shown as 0.
+ const hasAvailabilityData = ct.classes.some(
+ (c: any) => c.available != null,
+ );
+ const available = ct.classes.reduce(
+ (sum: number, c: any) => sum + (c.available ?? 0),
+ 0,
+ );
+ return (
+
+
+ {ct.coachTypeName}
+ {hasAvailabilityData && (
+
+ {available === 0 ? "Full" : `${available} left`}
+
+ )}
+
+ );
+ })}
+
+ )}
);
};
diff --git a/apps/edr-passenger-web/portal/src/types/index.ts b/apps/edr-passenger-web/portal/src/types/index.ts
index d1a906cbb..c299f10fd 100644
--- a/apps/edr-passenger-web/portal/src/types/index.ts
+++ b/apps/edr-passenger-web/portal/src/types/index.ts
@@ -51,6 +51,7 @@ export interface Schedule {
baseFareMinor: number;
displayCurrency?: string;
displayAmountMinor?: number;
+ available?: number;
}>;
}>;
displayCurrency?: string;
From ae2db7feb4d4f41d50309f628527270e2707f526 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 22:59:46 +0300
Subject: [PATCH 23/67] remove fare from seat selection summary
---
.../edr-passenger-web/portal/src/app/booking/seats/page.tsx | 6 ------
1 file changed, 6 deletions(-)
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 6cb41ec28..a143f7a1e 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
@@ -1927,7 +1927,6 @@ export default function SeatsPage() {
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
- const seatFare = assignedSeat ? getSeatFare(assignedSeat) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (
@@ -1977,11 +1976,6 @@ export default function SeatsPage() {
>
{assignedSeat ? `Seat ${seatLabel}` : "Not Assigned"}
- {assignedSeat && seatFare != null && (
-
- {currentSchedule?.displayCurrency || 'ETB'} {(seatFare / 100 * (isPackageBooking ? 2 : 1)).toFixed(2)}
-
- )}
);
From 25fdf88a77c9d8e647fee0d9c30830ef3061c524 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 00:39:53 +0300
Subject: [PATCH 24/67] Booking currency updates, audit logging updates
---
.../modules/bookings/bookings.controller.ts | 15 ++++++----
.../src/modules/bookings/bookings.dto.ts | 4 +--
.../src/modules/bookings/bookings.service.ts | 30 ++++++++++++++-----
.../modules/bookings/guest-booking.service.ts | 4 +--
.../modules/currencies/currencies.module.ts | 3 +-
.../modules/currencies/currencies.service.ts | 6 +++-
.../excess-baggage/excess-baggage.module.ts | 3 +-
.../excess-baggage/excess-baggage.service.ts | 7 ++++-
.../src/modules/fleet/fleet.module.ts | 3 +-
.../src/modules/fleet/fleet.service.ts | 26 +++++++++++-----
.../src/modules/packages/packages.module.ts | 3 +-
.../src/modules/packages/packages.service.ts | 23 ++++++++++----
.../modules/passengers/passengers.module.ts | 9 +++---
.../modules/passengers/passengers.service.ts | 4 ++-
.../src/modules/payments/payments.module.ts | 2 ++
.../src/modules/payments/payments.service.ts | 6 ++++
.../src/modules/schedules/routes.service.ts | 9 ++++--
.../src/modules/schedules/schedules.module.ts | 3 +-
.../modules/schedules/schedules.service.ts | 26 ++++++++++++----
.../seat-classes/seat-classes.module.ts | 3 +-
.../seat-classes/seat-classes.service.ts | 15 +++++++---
.../src/modules/seats/seats.module.ts | 3 +-
.../src/modules/seats/seats.service.ts | 8 +++--
.../src/modules/tickets/tickets.module.ts | 3 +-
.../src/modules/tickets/tickets.service.ts | 7 ++++-
.../portal/src/app/booking/review/page.tsx | 8 +++--
26 files changed, 169 insertions(+), 64 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
index 854621e0d..88bbe6ec4 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts
@@ -525,8 +525,11 @@ export class BookingsController {
"Missing required fields for bookingType, or Verifayda verification failed",
})
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
- create(@Body() dto: CreateBookingDto) {
- return this.service.create(dto);
+ create(@Req() req: any, @Body() dto: CreateBookingDto) {
+ // Always resolve passengerId from the authenticated JWT — never trust the request body
+ const iamUserId = req.user?.id;
+ if (!iamUserId) throw new UnauthorizedException();
+ return this.service.create({ ...dto, passengerId: iamUserId });
}
@Get(":id/usage")
@@ -612,8 +615,8 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
status: 400,
description: "Cannot modify cancelled or past bookings",
})
- modify(@Body() dto: ModifyBookingDto) {
- return this.service.modify(dto);
+ modify(@Req() req: any, @Body() dto: ModifyBookingDto) {
+ return this.service.modify(dto, req.user?.id);
}
@Delete(":id")
@@ -658,7 +661,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
description: "Booking cancelled with refund amount",
})
@ApiResponse({ status: 400, description: "Booking already cancelled" })
- cancel(@Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) {
- return this.service.cancel(ref, dto.reason);
+ cancel(@Req() req: any, @Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) {
+ return this.service.cancel(ref, dto.reason, req.user?.id);
}
}
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 4b355c1fe..73887454c 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -88,8 +88,8 @@ export class RoundTripPassengerDto {
}
export class CreateBookingDto {
- @ApiProperty({ description: 'Passenger ID' })
- @IsString() passengerId: string;
+ @ApiPropertyOptional({ description: 'Passenger ID — resolved automatically from JWT token; only required for agent/back-office calls' })
+ @IsOptional() @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;
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 70fef6b08..dea2517c0 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -12,6 +12,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
+import { AuditService } from '../../common/audit.service';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -104,6 +105,7 @@ export class BookingsService {
private readonly verifaydaService: VerifaydaService,
private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService,
+ private readonly auditService: AuditService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
@@ -151,7 +153,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -295,7 +297,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -408,7 +410,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -681,7 +683,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: resolvedEmail,
@@ -756,7 +758,14 @@ export class BookingsService {
};
}
- async create(dto: CreateBookingDto) {
+ async create(dto: CreateBookingDto) {
+ // Resolve passengerId from iamUserId when the caller is authenticated
+ if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
+ // passengerId is actually an iamUserId — resolve the passenger record
+ const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.passengerId }, select: { id: true } });
+ if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
+ dto = { ...dto, passengerId: passenger.id };
+ }
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
@@ -906,6 +915,7 @@ export class BookingsService {
});
}
this.eventEmitter.emit('booking.created', { booking });
+ await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
return { ...booking, fareBreakdown: fareCalculation };
}
@@ -1118,6 +1128,7 @@ export class BookingsService {
}
this.eventEmitter.emit('booking.created', { booking });
+ await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
return {
...booking,
@@ -1129,7 +1140,7 @@ export class BookingsService {
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor
}
@@ -1941,7 +1952,7 @@ export class BookingsService {
};
}
- async modify(dto: ModifyBookingDto) {
+ async modify(dto: ModifyBookingDto, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
@@ -1953,10 +1964,11 @@ export class BookingsService {
});
await this.seatsService.releaseSeats(booking.id);
await this.seatsService.confirmSeats(dto.newSeatIds);
+ await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
return { modified: true, bookingRef: dto.bookingRef };
}
- async cancel(bookingRef: string, reason?: string) {
+ async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
@@ -1965,6 +1977,7 @@ export class BookingsService {
await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
+ await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@@ -2031,6 +2044,7 @@ export class BookingsService {
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Booking', entityId: id, oldData: { bookingRef: booking.bookingRef } });
return { deleted: true, bookingRef: booking.bookingRef };
}
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 080b4404f..321e2269b 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -324,7 +324,7 @@ export class GuestBookingService {
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor: resolvedTotalMinor,
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor,
},
@@ -805,7 +805,7 @@ export class GuestBookingService {
paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayTotalMinor,
},
};
}
diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts
index 0adb94656..614359dff 100644
--- a/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts
+++ b/apps/edr-passenger-api/src/modules/currencies/currencies.module.ts
@@ -4,9 +4,10 @@ import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service';
import { CurrencyModule } from '../currency/currency.module';
import { PrismaModule } from '../../common/prisma.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [HttpModule, PrismaModule, CurrencyModule],
+ imports: [HttpModule, PrismaModule, CurrencyModule, AuditModule],
controllers: [CurrenciesController],
providers: [CurrenciesService],
exports: [CurrenciesService],
diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
index 8c875eb8b..648deb77c 100644
--- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
+++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts
@@ -2,12 +2,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
+import { AuditService } from '../../common/audit.service';
@Injectable()
export class CurrenciesService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
+ private auditService: AuditService,
) {}
async getAllCurrencies() {
@@ -57,6 +59,7 @@ export class CurrenciesService {
},
});
+ await this.auditService.log({ action: 'CREATE', entityType: 'Currency', entityId: rate.id, newData: { code, exchangeRate } });
return {
id: rate.id,
code: rate.toCurrency,
@@ -92,6 +95,7 @@ export class CurrenciesService {
'MANUAL',
);
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Currency', entityId: updated.id, newData: { exchangeRate: Number(updated.rate) } });
return {
id: updated.id,
code: updated.toCurrency,
@@ -125,7 +129,7 @@ export class CurrenciesService {
await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
});
-
+ await this.auditService.log({ action: 'DELETE', entityType: 'Currency', entityId: id, oldData: { toCurrency: existing.toCurrency } });
return { message: 'Currency deleted successfully' };
}
diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts
index e0545d44f..e734d4fb4 100644
--- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts
+++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.module.ts
@@ -7,9 +7,10 @@ import {
} from './excess-baggage.controller';
import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [HttpModule, PaymentsModule, NotificationsModule],
+ imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule],
controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController],
providers: [ExcessBaggageService],
exports: [ExcessBaggageService],
diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts
index 30ab0fd5e..ee968ba61 100644
--- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts
+++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts
@@ -5,6 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
+import { AuditService } from '../../common/audit.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
@@ -30,6 +31,7 @@ export class ExcessBaggageService {
constructor(
private prisma: PrismaService,
+ private auditService: AuditService,
private paymentClient: PaymentClientService,
private notifications: NotificationsService,
private smsClient: SmsClientService,
@@ -91,6 +93,7 @@ export class ExcessBaggageService {
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
}
+ await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
return charge;
}
@@ -208,10 +211,12 @@ export class ExcessBaggageService {
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
throw new BadRequestException('Cannot waive a charge that has already been paid');
}
- return this.prisma.excessBaggageCharge.update({
+ const waived = await this.prisma.excessBaggageCharge.update({
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
+ await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason } });
+ return waived;
}
async resendLink(id: string) {
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
index e2f4a28c9..dd39a72ec 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
+import { AuditModule } from '../../common/audit.module';
-@Module({ controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
+@Module({ imports: [AuditModule], controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
export class FleetModule {}
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
index dc29f4ecc..a4c1d8ccf 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
+import { AuditService } from '../../common/audit.service';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
@@ -147,7 +148,7 @@ type SeatRow = {
@Injectable()
export class FleetService {
- constructor(private prisma: PrismaService) {}
+ constructor(private prisma: PrismaService, private auditService: AuditService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
@@ -333,8 +334,8 @@ export class FleetService {
});
}
- createTrain(dto: CreateTrainDto) {
- return this.prisma.train.create({
+ async createTrain(dto: CreateTrainDto) {
+ const train = await this.prisma.train.create({
data: {
number: dto.number,
name: dto.name,
@@ -344,12 +345,14 @@ export class FleetService {
isActive: dto.isActive ?? true,
},
});
+ await this.auditService.log({ action: 'CREATE', entityType: 'Train', entityId: train.id, newData: { number: train.number, name: train.name } });
+ return train;
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
- return this.prisma.train.update({
+ const updated = await this.prisma.train.update({
where: { id },
data: {
number: dto.number,
@@ -360,6 +363,8 @@ export class FleetService {
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Train', entityId: id, newData: { number: dto.number, name: dto.name } });
+ return updated;
}
async deleteTrain(id: string, cascade = false) {
@@ -436,7 +441,9 @@ export class FleetService {
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
}
- return this.prisma.train.delete({ where: { id } });
+ const deleted = await this.prisma.train.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Train', entityId: id, oldData: { number: train.number, name: train.name } });
+ return deleted;
}
async restoreTrain(id: string) {
@@ -519,6 +526,7 @@ export class FleetService {
await this.prisma.seat.createMany({ data: seats });
}
+ await this.auditService.log({ action: 'CREATE', entityType: 'Coach', entityId: coach.id, newData: { number: coach.number, capacity: coach.capacity } });
return coach;
}
@@ -526,7 +534,7 @@ export class FleetService {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
- return this.prisma.coach.update({
+ const updated = await this.prisma.coach.update({
where: { id },
data: {
number: dto.number,
@@ -537,6 +545,8 @@ export class FleetService {
},
include: { coachType: true },
});
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Coach', entityId: id, newData: { number: dto.number, status: dto.status } });
+ return updated;
}
async deleteCoach(id: string, cascade = false) {
@@ -611,7 +621,9 @@ export class FleetService {
await this.prisma.seat.deleteMany({ where: { coachId: id } });
- return this.prisma.coach.delete({ where: { id } });
+ const deleted = await this.prisma.coach.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Coach', entityId: id, oldData: { number: coach.number } });
+ return deleted;
}
async assignCoach(dto: AssignCoachDto) {
diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts
index 32aec44fc..41e9da839 100644
--- a/apps/edr-passenger-api/src/modules/packages/packages.module.ts
+++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts
@@ -4,9 +4,10 @@ import { PackagesController } from './packages.controller';
import { PackagesService } from './packages.service';
import { CurrencyModule } from '../currency/currency.module';
import { BookingsModule } from '../bookings/bookings.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [PrismaModule, CurrencyModule, BookingsModule],
+ imports: [PrismaModule, CurrencyModule, BookingsModule, AuditModule],
controllers: [PackagesController],
providers: [PackagesService],
exports: [PackagesService],
diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts
index 916a0425b..9ddc1fb0a 100644
--- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts
+++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts
@@ -5,6 +5,7 @@ import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDt
import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
+import { AuditService } from '../../common/audit.service';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -49,6 +50,7 @@ export class PackagesService {
private readonly currencyService: CurrencyService,
private readonly bookingsService: BookingsService,
private readonly guestBookingService: GuestBookingService,
+ private readonly auditService: AuditService,
) {}
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
@@ -254,8 +256,8 @@ export class PackagesService {
};
}
- create(dto: CreatePackageDto) {
- return this.prisma.travelPackage.create({
+ async create(dto: CreatePackageDto) {
+ const pkg = await this.prisma.travelPackage.create({
data: {
code: dto.code,
name: dto.name,
@@ -279,12 +281,14 @@ export class PackagesService {
},
include: { priceTiers: true },
});
+ await this.auditService.log({ action: 'CREATE', entityType: 'Package', entityId: pkg.id, newData: { code: pkg.code, name: pkg.name } });
+ return pkg;
}
async update(id: string, dto: Partial) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
- return this.prisma.travelPackage.update({
+ const updated = await this.prisma.travelPackage.update({
where: { id },
data: {
...(dto.code && { code: dto.code }),
@@ -307,6 +311,8 @@ export class PackagesService {
},
include: { priceTiers: true },
});
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { code: dto.code, name: dto.name } });
+ return updated;
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
@@ -353,19 +359,24 @@ export class PackagesService {
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
await this.prisma.travelPackage.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
return { deleted: true };
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
- return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
+ const activated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'ACTIVE' } });
+ return activated;
}
async deactivate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
- return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
+ const deactivated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'DRAFT' } });
+ return deactivated;
}
async book(dto: BookPackageDto, passengerId?: string) {
@@ -478,7 +489,7 @@ export class PackagesService {
paidChildFareMinor: adultFareMinor,
childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`,
totalMinor,
- currency: 'ETB',
+ currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor,
},
diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts
index cc7748457..9fdb95b6c 100644
--- a/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts
+++ b/apps/edr-passenger-api/src/modules/passengers/passengers.module.ts
@@ -4,10 +4,11 @@ import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { PrismaModule } from '../../common/prisma.module';
+import { AuditModule } from '../../common/audit.module';
-@Module({
- imports: [VerifaydaModule, HttpModule, PrismaModule],
- controllers: [PassengersController],
- providers: [PassengersService]
+@Module({
+ imports: [VerifaydaModule, HttpModule, PrismaModule, AuditModule],
+ controllers: [PassengersController],
+ providers: [PassengersService],
})
export class PassengersModule {}
diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
index 2c52db955..742b7ae89 100644
--- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
+++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
+import { AuditService } from '../../common/audit.service';
interface PassengerFilters {
search?: string;
@@ -30,6 +31,7 @@ export class PassengersService {
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
+ private readonly auditService: AuditService,
) {}
async findAll(filters: PassengerFilters = {}) {
@@ -509,7 +511,7 @@ export class PassengersService {
await this.prisma.travelerProfile.deleteMany({ where: { passengerId } });
await this.prisma.savedRoute.deleteMany({ where: { passengerId } });
await this.prisma.passenger.delete({ where: { id: passengerId } });
-
+ await this.auditService.log({ action: 'DELETE', entityType: 'Passenger', entityId: passengerId });
return { deleted: true, passengerId };
}
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts
index 1b9af89f3..3c08eb3cd 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts
@@ -19,6 +19,7 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
import { CurrencyModule } from "../currency/currency.module";
+import { AuditModule } from "../../common/audit.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@@ -53,6 +54,7 @@ function rabbitMQImport(): DynamicModule[] {
SeatsModule,
TicketsModule,
CurrencyModule,
+ AuditModule,
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
HttpModule.register({
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 20fac61a5..5ef160135 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -26,6 +26,7 @@ import {
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
+import { AuditService } from "../../common/audit.service";
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
import {
PaymentService as PaymentServiceEnum,
@@ -64,6 +65,7 @@ export class PaymentsService {
private eventEmitter: EventEmitter2,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
+ private auditService: AuditService,
) {}
async deletePayment(id: string) {
@@ -592,6 +594,7 @@ export class PaymentsService {
data: { status: "CANCELLED" },
});
}
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
return { refunded: true, bookingRef: booking?.bookingRef };
}
@@ -898,6 +901,9 @@ export class PaymentsService {
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
+ }).then(async (result) => {
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
+ return result;
});
}
diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts
index e459e3663..9a9bdd30a 100644
--- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts
@@ -2,10 +2,11 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
+import { AuditService } from '../../common/audit.service';
@Injectable()
export class RoutesService {
- constructor(private prisma: PrismaService) {}
+ constructor(private prisma: PrismaService, private auditService: AuditService) {}
// ── Route CRUD ─────────────────────────────────────────────────────────────
@@ -22,7 +23,7 @@ export class RoutesService {
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
- return this.prisma.route.create({
+ const route = await this.prisma.route.create({
data: {
code: dto.code,
name: dto.name,
@@ -40,6 +41,8 @@ export class RoutesService {
},
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
});
+ await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } });
+ return route;
}
async listRoutes(activeOnly = false) {
@@ -104,6 +107,7 @@ export class RoutesService {
});
}
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
return this.prisma.route.findUnique({
where: { id },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -192,6 +196,7 @@ export class RoutesService {
}
await this.prisma.route.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } });
return { deleted: true, id };
}
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts
index 5eca9f1be..18d88d631 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts
@@ -4,9 +4,10 @@ import { SchedulesService } from './schedules.service';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [FareEngineModule],
+ imports: [FareEngineModule, AuditModule],
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
index 06bb4c2dc..7cca548aa 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts
@@ -5,6 +5,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
+import { AuditService } from '../../common/audit.service';
@Injectable()
export class SchedulesService {
@@ -12,6 +13,7 @@ export class SchedulesService {
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
+ private auditService: AuditService,
) { }
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
@@ -191,7 +193,9 @@ export class SchedulesService {
);
}
- return this.getSchedule(schedule.id);
+ const result = await this.getSchedule(schedule.id);
+ await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
+ return result;
}
async getSchedule(id: string) {
@@ -325,10 +329,12 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
- return this.getSchedule(id);
+ const result = await this.getSchedule(id);
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } });
+ return result;
}
- updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
+ async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
}
@@ -409,7 +415,9 @@ export class SchedulesService {
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
- return this.prisma.trainSchedule.delete({ where: { id } });
+ await this.prisma.trainSchedule.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'Schedule', entityId: id });
+ return { deleted: true, id };
}
getStops(scheduleId: string) {
@@ -467,7 +475,7 @@ export class SchedulesService {
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
- return this.prisma.fareRule.create({
+ const result = this.prisma.fareRule.create({
data: {
...rest,
tripId: scheduleId,
@@ -477,6 +485,8 @@ export class SchedulesService {
},
include: { seatClass: true },
});
+ result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } }));
+ return result;
}
async updateFareRule(id: string, dto: Partial) {
@@ -501,6 +511,7 @@ export class SchedulesService {
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Fare rule not found');
await this.prisma.fareRule.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'FareRule', entityId: id });
return { deleted: true, id };
}
@@ -701,7 +712,7 @@ export class SchedulesService {
]);
if (!route) throw new NotFoundException('Route not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
- return this.prisma.routeFareRule.create({
+ const rule = await this.prisma.routeFareRule.create({
data: {
routeId: dto.routeId,
seatClassId: dto.seatClassId,
@@ -712,6 +723,8 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
+ await this.auditService.log({ action: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } });
+ return rule;
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
@@ -733,6 +746,7 @@ export class SchedulesService {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'RouteFareRule', entityId: id });
return { deleted: true, id };
}
}
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts
index a7e8648e1..1882bd577 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { SeatClassesController } from './seat-classes.controller';
import { SeatClassesService } from './seat-classes.service';
+import { AuditModule } from '../../common/audit.module';
-@Module({ controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] })
+@Module({ imports: [AuditModule], controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] })
export class SeatClassesModule {}
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
index 99c67a665..f33ecac64 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts
@@ -1,10 +1,11 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
+import { AuditService } from '../../common/audit.service';
@Injectable()
export class SeatClassesService {
- constructor(private prisma: PrismaService) {}
+ constructor(private prisma: PrismaService, private auditService: AuditService) {}
listSeatClasses() {
return this.prisma.seatClass.findMany({
@@ -28,7 +29,9 @@ export class SeatClassesService {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
- return this.prisma.seatClass.update({ where: { id }, data });
+ const updated = await this.prisma.seatClass.update({ where: { id }, data });
+ await this.auditService.log({ action: 'UPDATE', entityType: 'SeatClass', entityId: id, newData: { name: updated.name } });
+ return updated;
}
async createSeatClass(dto: any) {
@@ -38,7 +41,9 @@ export class SeatClassesService {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
- return await this.prisma.seatClass.create({ data });
+ const sc = await this.prisma.seatClass.create({ data });
+ await this.auditService.log({ action: 'CREATE', entityType: 'SeatClass', entityId: sc.id, newData: { name: sc.name } });
+ return sc;
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
@@ -70,6 +75,8 @@ export class SeatClassesService {
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
- return this.prisma.seatClass.delete({ where: { id } });
+ const deleted = await this.prisma.seatClass.delete({ where: { id } });
+ await this.auditService.log({ action: 'DELETE', entityType: 'SeatClass', entityId: id, oldData: { name: sc.name } });
+ return deleted;
}
}
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts
index 425f517c6..0725f4393 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts
@@ -4,9 +4,10 @@ import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [SegmentsModule, HttpModule, SystemConfigModule],
+ imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 3d485d22c..ad92660a4 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -4,6 +4,7 @@ import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
+import { AuditService } from '../../common/audit.service';
@Injectable()
export class SeatsService {
@@ -11,6 +12,7 @@ export class SeatsService {
private prisma: PrismaService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
+ private auditService: AuditService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
@@ -803,7 +805,7 @@ export class SeatsService {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
-
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
return { blocked: true, seatId, reason };
}
@@ -813,7 +815,7 @@ export class SeatsService {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
-
+ await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
return { unblocked: true, seatId };
}
@@ -846,7 +848,7 @@ export class SeatsService {
});
await this.renumberCoachSeats(seat.coachId);
-
+ await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } });
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts
index 1ccc2e392..2056d4eda 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.module.ts
@@ -4,9 +4,10 @@ import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module';
import { SystemConfigModule } from '../system-config/system-config.module';
+import { AuditModule } from '../../common/audit.module';
@Module({
- imports: [NotificationsModule, SystemConfigModule],
+ imports: [NotificationsModule, SystemConfigModule, AuditModule],
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
index 656e726ab..648b43cad 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
@@ -4,6 +4,7 @@ import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
+import { AuditService } from '../../common/audit.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -22,6 +23,7 @@ export class TicketsService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly systemConfig: SystemConfigService,
+ private readonly auditService: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -229,7 +231,6 @@ export class TicketsService {
}
}
- // Delete existing tickets if any
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
@@ -291,6 +292,7 @@ export class TicketsService {
}).catch(() => null);
}
+ await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } });
return { tickets, totalTickets: tickets.length };
}
@@ -557,6 +559,7 @@ export class TicketsService {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
+ await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -575,6 +578,7 @@ export class TicketsService {
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
+ await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -608,6 +612,7 @@ export class TicketsService {
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
+ await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
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 f36324bd1..10051f0d3 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
@@ -56,9 +56,13 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- // Derive display currency from nationality so fares show in the passenger's home currency.
+ // Use the display currency stored on the schedule (set at search/selection time).
+ // Fall back to nationality-based derivation only if the schedule has no displayCurrency.
+ const scheduleCurrency = isRoundTrip
+ ? outboundSchedule?.displayCurrency
+ : selectedSchedule?.displayCurrency;
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
- const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const displayCurrencyCode = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
useEffect(() => {
if (!seatHold?.expiresAt) return;
From f8658d63b815ae2d0de1038d6f64f001d88b972d Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 00:45:24 +0300
Subject: [PATCH 25/67] feat: ( currency ) add multiple from currency
---
.../src/modules/currency/currency.service.ts | 23 +++++++++++--------
.../src/modules/payments/payments.service.ts | 3 ++-
2 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
index 806ab423e..57276b116 100644
--- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts
+++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
@@ -51,26 +51,31 @@ export class CurrencyService {
}
/**
- * Converts an ETB minor-unit amount to the charge major-unit amount sent to the
- * payment provider. Applies the exchange rate for foreign currencies then divides
- * by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
+ * Converts a booking's stored minor-unit amount (in its own `fromCurrency`) to the charge
+ * major-unit amount sent to the payment provider in `targetCurrency`. When the two currencies
+ * match, no exchange rate is applied — the stored amount is charged as-is. Otherwise the
+ * fromCurrency→targetCurrency rate is applied. In both cases the result is divided by 100 to
+ * yield major units and rounded to the target currency's precision
+ * (e.g. 300000 ETB minor → 3000.00 ETB major; DJF rounds to whole francs).
*/
- async convertEtbMinorToChargeMajor(
- amountMinorEtb: number,
+ async convertMinorToChargeMajor(
+ amountMinor: number,
+ fromCurrency: string,
targetCurrency: string,
): Promise {
+ const from = fromCurrency.toUpperCase();
const target = targetCurrency.toUpperCase();
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
}
const decimals = CHARGE_CURRENCY_DECIMALS[target];
- if (target === Currency.ETB) {
- return this.roundTo(amountMinorEtb / 100, decimals);
+ if (from === target) {
+ return this.roundTo(amountMinor / 100, decimals);
}
- const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
- return this.roundTo((amountMinorEtb * rate) / 100, decimals);
+ const rate = await this.getRateOrThrow(from as Currency, target as Currency);
+ return this.roundTo((amountMinor * rate) / 100, decimals);
}
async getRateOrThrow(
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 20fac61a5..ca1c116c8 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -240,8 +240,9 @@ export class PaymentsService {
const chargeCurrency = (
paymentMethod?.currency ?? booking.currency
).toUpperCase();
- const chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
+ const chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
+ booking.currency,
chargeCurrency,
);
From 64b362ff51227d7fed8a9d1c9ae5136b49b5ef3e Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 08:13:23 +0300
Subject: [PATCH 26/67] Fix expired seat
---
.../src/modules/seats/seats.service.ts | 87 +++++++++++++++----
.../segments/enhanced-seats.service.ts | 72 ++++++++++++---
2 files changed, 132 insertions(+), 27 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 3d485d22c..ab0791352 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
+import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
@@ -7,6 +7,8 @@ import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config
@Injectable()
export class SeatsService {
+ private readonly logger = new Logger(SeatsService.name);
+
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -891,30 +893,83 @@ export class SeatsService {
);
}
+ // Runs every minute, but is also safe to call on-demand (e.g. right after a hold's
+ // TTL is read back to the client) — expiresAt/now are both absolute UTC instants
+ // (Date objects, not wall-clock strings), so this is correct regardless of the
+ // server's or a client's local timezone; there's no wall-clock parsing involved.
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
+ try {
+ const result = await this.expireHoldsCore();
+ if (result.expiredHolds > 0) {
+ this.logger.log(
+ `Expired ${result.expiredHolds} hold(s): released ${result.releasedSeatIds.length} seat(s), ` +
+ `skipped ${result.skippedSeatIds.length} still held by another active hold on the same schedule`,
+ );
+ }
+ } catch (error) {
+ // A failed run must not crash the process or silently go unnoticed — the next
+ // scheduled run one minute later will retry the same (still-expired) holds,
+ // since nothing here is deleted/updated until the queries above succeed.
+ this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
+ }
+ }
+
+ async expireHoldsCore(now: Date = new Date()): Promise<{
+ expiredHolds: number;
+ releasedSeatIds: string[];
+ skippedSeatIds: string[];
+ }> {
const expired = await this.prisma.seatHold.findMany({
- where: { expiresAt: { lt: new Date() } },
- select: { id: true, seatIds: true },
+ where: { expiresAt: { lt: now } },
+ select: { id: true, scheduleId: true, seatIds: true },
});
- if (expired.length === 0) return;
+ if (expired.length === 0) {
+ return { expiredHolds: 0, releasedSeatIds: [], skippedSeatIds: [] };
+ }
- const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
-
- // Only reset seats that have no remaining active holds
- const stillHeld = await this.prisma.seatHold.findMany({
- where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } },
- select: { seatIds: true },
+ // Still-active holds — scoped per (scheduleId, seatId), not just seatId. The same
+ // physical Seat row is reused across every recurring date a coach runs, so the
+ // same seatId legitimately appears in unrelated holds for other schedules; without
+ // this scoping, an unrelated active hold on a DIFFERENT schedule would wrongly
+ // block release of a seat whose hold expired on THIS schedule, leaving it stuck at
+ // status 'HELD' indefinitely.
+ const activeHolds = await this.prisma.seatHold.findMany({
+ where: { expiresAt: { gte: now } },
+ select: { scheduleId: true, seatIds: true },
});
- const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[]));
- const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id));
+ const stillHeldKeys = new Set(
+ activeHolds.flatMap(h => (h.seatIds as string[]).map(seatId => `${h.scheduleId}:${seatId}`)),
+ );
- if (toRelease.length > 0) {
+ const releasedSeatIds = new Set();
+ const skippedSeatIds = new Set();
+ for (const hold of expired) {
+ for (const seatId of hold.seatIds as string[]) {
+ if (stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) {
+ skippedSeatIds.add(seatId);
+ } else {
+ releasedSeatIds.add(seatId);
+ }
+ }
+ }
+
+ if (releasedSeatIds.size > 0) {
await this.prisma.seat.updateMany({
- where: { id: { in: toRelease }, status: 'HELD' },
- data: { status: 'AVAILABLE' },
+ where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
+ // heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
+ // AVAILABLE seat is stale data that any future code reading heldUntil directly
+ // (instead of re-deriving availability live) would misinterpret.
+ data: { status: 'AVAILABLE', heldUntil: null },
});
}
- await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
+
+ await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
+
+ return {
+ expiredHolds: expired.length,
+ releasedSeatIds: Array.from(releasedSeatIds),
+ skippedSeatIds: Array.from(skippedSeatIds),
+ };
}
}
diff --git a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
index 406c9e61b..bd99f1ceb 100644
--- a/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/segments/enhanced-seats.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
+import { Injectable, BadRequestException, ConflictException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SegmentsService, Segment } from '../segments/segments.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@@ -18,6 +18,8 @@ export interface BookingConfirmRequest {
@Injectable()
export class EnhancedSeatsService {
+ private readonly logger = new Logger(EnhancedSeatsService.name);
+
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
@@ -57,6 +59,15 @@ export class EnhancedSeatsService {
},
});
+ // Mirrors SeatsService.holdSeats() — without this, a seat held through this path
+ // reads back as status 'AVAILABLE' in the DB despite being actively held, which is
+ // wrong for any consumer that trusts `status` directly instead of re-deriving
+ // availability live from SeatHold.
+ await tx.seat.updateMany({
+ where: { id: { in: request.seatIds } },
+ data: { status: 'HELD', heldUntil: expiresAt },
+ });
+
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
@@ -157,19 +168,58 @@ export class EnhancedSeatsService {
});
}
- async expireHolds() {
- return this.prisma.$transaction(async (tx) => {
- const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
- const expiredSeatIds = expiredHolds.flatMap(h => h.seatIds);
+ // now/expiresAt are absolute UTC instants (Date objects), not wall-clock strings, so
+ // this comparison is correct regardless of the server's local timezone.
+ async expireHolds(now: Date = new Date()) {
+ try {
+ const result = await this.prisma.$transaction(async (tx) => {
+ const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: now } } });
+ if (expiredHolds.length === 0) {
+ return { expiredHolds: 0, releasedSeats: [] as string[] };
+ }
- if (expiredSeatIds.length > 0) {
- await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } });
- await tx.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
- this.eventEmitter.emit('holds.expired', { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds });
+ // Still-active holds — scoped per (scheduleId, seatId). The same physical Seat
+ // row is reused across every recurring date a coach runs, so the same seatId can
+ // legitimately appear in an unrelated hold for a different schedule; without this
+ // scoping, that unrelated hold would wrongly be treated as covering THIS
+ // schedule's seat too, and a seat still genuinely held (same schedule, a newer
+ // non-expired hold) could be released out from under it.
+ const activeHolds = await tx.seatHold.findMany({ where: { expiresAt: { gte: now } } });
+ const stillHeldKeys = new Set(
+ activeHolds.flatMap(h => h.seatIds.map(seatId => `${h.scheduleId}:${seatId}`)),
+ );
+
+ const releasedSeatIds = new Set();
+ for (const hold of expiredHolds) {
+ for (const seatId of hold.seatIds) {
+ if (!stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) releasedSeatIds.add(seatId);
+ }
+ }
+
+ if (releasedSeatIds.size > 0) {
+ await tx.seat.updateMany({
+ where: { id: { in: Array.from(releasedSeatIds) } },
+ data: { status: 'AVAILABLE', heldUntil: null },
+ });
+ }
+ await tx.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
+
+ return { expiredHolds: expiredHolds.length, releasedSeats: Array.from(releasedSeatIds) };
+ });
+
+ if (result.expiredHolds > 0) {
+ this.logger.log(`Expired ${result.expiredHolds} hold(s), released ${result.releasedSeats.length} seat(s)`);
+ this.eventEmitter.emit('holds.expired', { expiredHolds: result.expiredHolds, releasedSeats: result.releasedSeats });
}
- return { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds };
- });
+ return result;
+ } catch (error) {
+ // A failed run must not go unnoticed — nothing is deleted/updated until the
+ // transaction commits, so the next caller/scheduled run simply retries the same
+ // still-expired holds.
+ this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
+ return { expiredHolds: 0, releasedSeats: [] as string[] };
+ }
}
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
From 6a9227b0f6d4c0ec3af6c8bbadbe19581847cc52 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 09:15:49 +0300
Subject: [PATCH 27/67] Extend seat hold time if booking created
---
.../common/utils/payment-deadline.utils.ts | 22 +++++++++
.../src/modules/seats/seats.service.ts | 46 ++++++++++++++++++-
.../src/modules/tasks/tasks.service.ts | 30 ++++++------
3 files changed, 82 insertions(+), 16 deletions(-)
create mode 100644 apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
new file mode 100644
index 000000000..1d4c286be
--- /dev/null
+++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts
@@ -0,0 +1,22 @@
+/**
+ * Single source of truth for how long a PENDING_PAYMENT booking has to be paid for,
+ * shared by TasksService (which auto-cancels bookings past this deadline) and
+ * SeatsService (which extends the seat hold to cover exactly this window when a
+ * booking/PNR is created — without this, the seat hold reverted to its original
+ * short seat-selection TTL and could expire mid-payment, letting a second customer
+ * grab the same seat).
+ */
+
+/** Maximum time (hours) a passenger has to pay after booking. */
+export const MAX_PAYMENT_HOURS = 2;
+/** Minutes before departure: cutoff for new bookings and payment deadline. */
+export const CUTOFF_MINUTES = 30;
+
+/**
+ * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
+ */
+export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
+ const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
+ const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
+ return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
+}
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 1d17e9826..b2bff14b6 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
+import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
@Injectable()
export class SeatsService {
@@ -625,7 +626,50 @@ export class SeatsService {
return { released: true, holdId };
}
- async confirmSeats(_seatIds: string[]) {}
+ // Called right after a booking (PNR) is created, and again on successful payment.
+ // Extends the SeatHold(s) covering these seats to the booking's actual payment
+ // deadline — the same MIN(createdAt + 2h, departureAt - 30min) window TasksService
+ // uses to auto-cancel unpaid bookings — instead of leaving them on the original
+ // short seat-selection hold (5 min by default). Without this, the hold could expire
+ // while the customer was still on the payment page, and a second customer could
+ // hold/book the exact same seat out from under them.
+ async confirmSeats(seatIds: string[], now: Date = new Date()): Promise {
+ if (seatIds.length === 0) return;
+
+ const holds = await this.prisma.seatHold.findMany({
+ where: { seatIds: { hasSome: seatIds } },
+ select: { id: true, scheduleId: true, expiresAt: true },
+ });
+ if (holds.length === 0) return;
+
+ const scheduleIds = Array.from(new Set(holds.map(h => h.scheduleId)));
+ const schedules = await this.prisma.trainSchedule.findMany({
+ where: { id: { in: scheduleIds } },
+ select: { id: true, departureAt: true },
+ });
+ const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
+
+ let extended = 0;
+ await Promise.all(
+ holds.map(async (hold) => {
+ const departureAt = departureById.get(hold.scheduleId);
+ if (!departureAt) return;
+ const deadline = computePaymentDeadline(now, departureAt);
+ // Only ever extend forward — never shorten a hold that's already valid longer
+ // than the payment deadline would give it (e.g. a second confirmSeats call on
+ // the same booking, or a hold that was already extended).
+ if (deadline <= hold.expiresAt) return;
+ await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
+ extended++;
+ }),
+ );
+
+ if (extended > 0) {
+ this.logger.log(
+ `Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
+ );
+ }
+ }
// Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) {
diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
index fb9957b92..4fb3a4f0f 100644
--- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
+++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts
@@ -3,11 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
-
-/** Maximum time (hours) a passenger has to pay after booking. */
-const MAX_PAYMENT_HOURS = 2;
-/** Minutes before departure: cutoff for new bookings and payment deadline. */
-const CUTOFF_MINUTES = 30;
+import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
const OTP_RETENTION_HOURS = 1;
@@ -16,15 +12,6 @@ const AUDIT_LOG_RETENTION_DAYS = 365;
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
const GATE_LOG_RETENTION_DAYS = 180;
-/**
- * payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
- */
-function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
- const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
- const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
- return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
-}
-
function fmtTime(d: Date): string {
return d.toLocaleTimeString('en-GB', {
hour: '2-digit',
@@ -192,6 +179,7 @@ export class TasksService {
},
},
paymentIntent: { select: { method: true } },
+ seats: { select: { seatId: true } },
},
});
@@ -205,9 +193,21 @@ export class TasksService {
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
- // 1. Release held seats (Journey rows are the occupancy source of truth)
+ // 1a. Release held seats (Journey rows are the occupancy source of truth once paid)
await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any });
+ // 1b. Also release the SeatHold(s) covering this booking's seats — SeatsService
+ // extends these to the payment deadline when the booking is created, so without
+ // this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
+ // though the booking is now cancelled. Scoped to this booking's own schedule,
+ // since the same physical Seat row is reused across other recurring dates.
+ const seatIds = booking.seats.map(s => s.seatId);
+ if (seatIds.length > 0) {
+ await this.prisma.seatHold.deleteMany({
+ where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },
+ });
+ }
+
// 2. Audit record (no refund — payment was never completed)
await this.prisma.bookingCancellation.create({
data: {
From 9be7f356f0a47baba3e714bb25def8ee023ffa2b Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 07:07:10 +0000
Subject: [PATCH 28/67] enhance train scheduling logic to exclude cancelled
trains and refine window filtering
---
.../train-scheduling/train-scheduling.service.ts | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 04583c777..a0b1591f3 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
@@ -435,7 +435,12 @@ export class TrainSchedulingService {
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
- .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
+ .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
+ // A cancelled train is not a sibling: cancel retires its window as DONE,
+ // and a newborn anchoring to it would inherit that dead window verbatim.
+ .andWhere('s.status != :cancelledStatus', {
+ cancelledStatus: TrainScheduleStatusEnum.Cancelled,
+ });
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -471,7 +476,12 @@ export class TrainSchedulingService {
departure,
);
if (siblings.length === 0) return null;
- const withWindow = siblings.filter((s) => s.windowOpensAt != null);
+ // A DONE window is retired (the day's last cycle already ran) — anchoring
+ // to it would hand the newborn a dead window no tick ever advances. With no
+ // live or pending sibling left, fall back to fresh times (return null).
+ const withWindow = siblings.filter(
+ (s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
+ );
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
From e2529b9317a6cf93083795738f7349ff5cc04fcb Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 10:27:17 +0300
Subject: [PATCH 29/67] Payment amount for non ETB and exchange fixes
---
.../src/modules/bookings/bookings.dto.ts | 8 +-
.../src/modules/bookings/bookings.service.ts | 57 ++++++-------
.../src/modules/bookings/guest-booking.dto.ts | 4 +-
.../modules/bookings/guest-booking.service.ts | 47 ++++++-----
.../src/modules/currency/currency.service.ts | 29 +++++--
.../modules/payments/payments.controller.ts | 2 +-
.../modules/payments/payments.service.spec.ts | 35 ++++++++
.../src/modules/payments/payments.service.ts | 76 +++++++++++++----
.../portal/src/app/booking/payment/page.tsx | 83 +++++++++----------
9 files changed, 216 insertions(+), 125 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
index 73887454c..42d4af0d9 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsNumber, IsEnum, IsDate, MaxDate } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -21,8 +21,8 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
- @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
- @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsNumber() seatFareMinor?: number;
+ @ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsNumber() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -146,7 +146,7 @@ export class CreateBookingDto {
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' })
- @IsOptional() @IsInt() reviewedTotalMinor?: number;
+ @IsOptional() @IsNumber() reviewedTotalMinor?: number;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;
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 dea2517c0..cffa01c4b 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -846,23 +846,22 @@ export class BookingsService {
// Free children have no seatId and no seatFareMinor — exclude them from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
- // reviewedTotalMinor is now sent in display-currency minor units from the review page.
- // When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
+ // seatFareMinor values from the client are in display-currency minor units (matching
+ // displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
+ // In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = dto.reviewedTotalMinor;
- resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
- } else {
- resolvedTotalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = dto.reviewedTotalMinor;
- }
+ displayTotalMinor = dto.reviewedTotalMinor;
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
+ : dto.reviewedTotalMinor;
} else if (allFaresProvided) {
- resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
- : resolvedTotalMinor;
+ // seatFareMinor is in display currency — sum is already the display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -880,7 +879,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
- totalMinor: resolvedTotalMinor / 100,
+ totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -1001,10 +1000,10 @@ export class BookingsService {
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
- let displayTotalMinor = totalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
- }
+ // displayTotalMinor will be overridden below when reviewedTotalMinor is provided.
+ let displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
+ : totalMinor;
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
@@ -1036,20 +1035,16 @@ export class BookingsService {
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = dto.reviewedTotalMinor;
- totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
- } else {
- totalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = dto.reviewedTotalMinor;
- }
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
+ : dto.reviewedTotalMinor;
} else if (allRTFaresProvided && !dto.packageId) {
- totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
- } else {
- displayTotalMinor = totalMinor;
- }
+ // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
}
const booking = await this.prisma.booking.create({
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
index f5a5559bb..f89b5228d 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts
@@ -1,4 +1,4 @@
-import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
+import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -158,7 +158,7 @@ export class CreateGuestBookingDto {
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
- @IsOptional() @IsInt() reviewedTotalMinor?: number;
+ @IsOptional() @IsNumber() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 321e2269b..259beb404 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -221,20 +221,28 @@ export class GuestBookingService {
return { ...p, fareMinor };
});
- // Use reviewedTotalMinor from frontend as authoritative total when provided.
- // Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
+ // reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
+ // Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
+ const displayCurrency = dto.displayCurrency || Currency.ETB;
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
- const resolvedTotalMinor = dto.reviewedTotalMinor ??
- (allFaresProvided
- ? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
- : Math.max(0, totalBaseFareMinor - discountMinor));
- const displayCurrency = dto.displayCurrency || Currency.ETB;
- let displayTotalMinor = resolvedTotalMinor;
- if (displayCurrency !== Currency.ETB) {
- displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
+ let displayTotalMinor: number;
+ let resolvedTotalMinor: number;
+ if (dto.reviewedTotalMinor != null) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ } else if (allFaresProvided) {
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
+ } else {
+ // fare engine returns ETB — convert forward to display currency
+ const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
+ displayTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
+ : etbTotal;
}
+ resolvedTotalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
// Resolve or create the guest Passenger record
const firstPassenger = passengersData[0];
@@ -501,16 +509,17 @@ export class GuestBookingService {
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
- if (dto.reviewedTotalMinor) {
- totalMinor = dto.reviewedTotalMinor;
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
- : totalMinor;
+ if (dto.reviewedTotalMinor != null) {
+ displayTotalMinor = dto.reviewedTotalMinor;
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
} else if (allRTFaresProvided && !isPackageRoundTrip) {
- totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
- displayTotalMinor = displayCurrency !== Currency.ETB
- ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
- : totalMinor;
+ // seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
+ displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
+ totalMinor = displayCurrency !== Currency.ETB
+ ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
+ : displayTotalMinor;
}
// Create or resolve guest passenger (same as one-way)
diff --git a/apps/edr-passenger-api/src/modules/currency/currency.service.ts b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
index 806ab423e..198571572 100644
--- a/apps/edr-passenger-api/src/modules/currency/currency.service.ts
+++ b/apps/edr-passenger-api/src/modules/currency/currency.service.ts
@@ -78,16 +78,31 @@ export class CurrencyService {
toCurrency: Currency,
): Promise {
if (fromCurrency === toCurrency) return 1;
- const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
+
+ // Direct rate
+ const direct = await this.prisma.currencyExchangeRate.findFirst({
where: { fromCurrency, toCurrency },
orderBy: { effectiveDate: 'desc' },
});
- if (!exchangeRate) {
- throw new BadRequestException(
- `No exchange rate configured for ${fromCurrency}->${toCurrency}`,
- );
+ if (direct) return Number(direct.rate);
+
+ // Inverse rate
+ const inverse = await this.prisma.currencyExchangeRate.findFirst({
+ where: { fromCurrency: toCurrency, toCurrency: fromCurrency },
+ orderBy: { effectiveDate: 'desc' },
+ });
+ if (inverse) return 1 / Number(inverse.rate);
+
+ // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
+ if (fromCurrency !== Currency.ETB && toCurrency !== Currency.ETB) {
+ const toEtb = await this.getRateOrThrow(fromCurrency, Currency.ETB);
+ const etbToTarget = await this.getRateOrThrow(Currency.ETB, toCurrency);
+ return toEtb * etbToTarget;
}
- return Number(exchangeRate.rate);
+
+ throw new BadRequestException(
+ `No exchange rate configured for ${fromCurrency}->${toCurrency}`,
+ );
}
private roundTo(value: number, decimals: number): number {
@@ -105,7 +120,7 @@ export class CurrencyService {
}
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
- return Math.round(amountMinor * rate);
+ return amountMinor * rate;
}
async getExchangeRate(
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index f917a9590..50cd99ec4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -230,7 +230,7 @@ export class PaymentsController {
@ApiOperation({
summary: "Get booking amount in a specific currency",
description:
- "Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
+ "Returns the booking total converted from the booking's stored currency to the requested currency using the latest exchange rate. " +
"If currency is ETB the stored amount is returned as-is (no conversion). " +
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
})
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
index bc5faee95..5271acef4 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts
@@ -38,6 +38,9 @@ describe("PaymentsService", () => {
paymentMethod: {
findUnique: jest.fn(),
},
+ currencyExchangeRate: {
+ findFirst: jest.fn(),
+ },
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -338,6 +341,38 @@ describe("PaymentsService", () => {
});
});
+ describe("getBookingAmountByCurrency", () => {
+ it("should convert from the booking currency to the requested currency", async () => {
+ mockPrisma.booking.findUnique.mockResolvedValue({
+ id: "booking-1",
+ totalMinor: 100000,
+ bookingType: "ONE_WAY",
+ packageId: null,
+ priceTierId: null,
+ currency: "USD",
+ displayCurrency: "USD",
+ displayTotalMinor: 125000,
+ });
+ mockPrisma.currencyExchangeRate.findFirst.mockResolvedValue({ rate: 2.5 });
+
+ const result = await service.getBookingAmountByCurrency("booking-1", "DJF");
+
+ expect(result).toEqual({
+ booking_id: "booking-1",
+ currency: "DJF",
+ amount: 3125,
+ });
+ expect(mockPrisma.currencyExchangeRate.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ fromCurrency: "USD",
+ toCurrency: "DJF",
+ }),
+ }),
+ );
+ });
+ });
+
describe("getIntentByBookingId", () => {
it("should return the cached local intent when the payment service has none", async () => {
const mockIntent = {
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 5ef160135..495edc17c 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -234,18 +234,36 @@ export class PaymentsService {
);
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
- // settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into
- // that currency here so the payment microservice stays currency-agnostic and charges it as-is.
+ // settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
+ // already matches the charge currency, use displayTotalMinor directly — the rate is already
+ // baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
const paymentMethod = await this.prisma.paymentMethod.findUnique({
where: { type: method },
});
const chargeCurrency = (
paymentMethod?.currency ?? booking.currency
).toUpperCase();
- const chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
- booking.totalMinor,
- chargeCurrency,
- );
+
+ const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
+ const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
+
+ let chargeAmount: number;
+ if (
+ chargeCurrency === bookingDisplayCurrency &&
+ chargeCurrency !== 'ETB' &&
+ bookingDisplayTotalMinor != null
+ ) {
+ // Display currency matches charge currency — use the pre-converted amount directly.
+ chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
+ } else if (chargeCurrency === 'ETB') {
+ chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
+ } else {
+ // Booking is in ETB — convert to the provider's settlement currency.
+ chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
+ booking.totalMinor,
+ chargeCurrency,
+ );
+ }
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
@@ -657,28 +675,54 @@ export class PaymentsService {
): Promise<{ booking_id: string; currency: string; amount: number }> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
- select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true },
+ select: {
+ id: true,
+ totalMinor: true,
+ bookingType: true,
+ packageId: true,
+ priceTierId: true,
+ currency: true,
+ displayCurrency: true,
+ displayTotalMinor: true,
+ },
});
if (!booking) throw new NotFoundException('Booking not found');
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
const requestedCurrency = currency.toUpperCase();
- const amountInETB = correctTotalMinor / 100;
- if (requestedCurrency === 'ETB') {
- return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
+ // Source of truth: displayTotalMinor in displayCurrency when available,
+ // otherwise totalMinor in ETB (bookings with no display currency override).
+ const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
+ const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
+
+ // Same currency — return directly, no conversion needed.
+ if (requestedCurrency === sourceCurrency) {
+ return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
}
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
- where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
+ where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
orderBy: { effectiveDate: 'desc' },
});
- if (!exchangeRate) {
- throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
- }
- const rate = Number(exchangeRate.rate);
- const converted = parseFloat((amountInETB * rate).toFixed(2));
+ let rate: number;
+ if (exchangeRate) {
+ rate = Number(exchangeRate.rate);
+ } else {
+ // Try inverse rate
+ const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
+ where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
+ orderBy: { effectiveDate: 'desc' },
+ });
+ if (inverseRate) {
+ rate = 1 / Number(inverseRate.rate);
+ } else {
+ // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
+ rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
+ }
+ }
+ const converted = (sourceMinor / 100) * rate;
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
index e56680d11..4333e95e9 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx
@@ -34,7 +34,6 @@ export default function PaymentPage() {
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
const [selectedMethod, setSelectedMethod] = useState(null);
- const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState(null);
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
@@ -47,40 +46,40 @@ export default function PaymentPage() {
const [otpError, setOtpError] = useState(null);
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
- const isPackage = !!packageName;
- // Use the same display currency as the review page (derived from nationality)
+ // Use the same display currency as the review page — stored on the schedule at search time.
+ const scheduleCurrency = isRoundTrip
+ ? outboundSchedule?.displayCurrency
+ : selectedSchedule?.displayCurrency;
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
- const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
+ const displayCurrency = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery({
- queryKey: ['paymentMethods', displayCurrency],
+ queryKey: ['paymentMethods'],
queryFn: async () => {
- const response = await apiClient.get(`/payments/methods?currency=${displayCurrency}`);
+ const response = await apiClient.get(`/payments/methods`);
return Array.isArray(response) ? response : [];
},
});
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
- // A payment method only needs a currency conversion when its own currency differs from
- // the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
- // total already shown on the review page is exact and there's nothing to convert.
- const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
- const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
+ // Derive charge currency directly from the selected method — no separate state that can lag.
+ const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
- // Fetch the converted booking amount from the booking-amount-changer API whenever a
- // currency-specific payment method is selected.
- const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
+ const { data: bookingAmountData, isFetching: fetchingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
queryKey: ['bookingAmount', bookingId, amountCurrency],
queryFn: async () => {
- const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
- const response: any = await apiClient.get(url);
+ const response: any = await apiClient.get(`/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`);
return response;
},
- enabled: !!bookingId && isConversionNeeded,
+ enabled: !!bookingId && !!selectedMethod,
+ staleTime: 30_000,
});
+ // Data is only usable when it belongs to the currently-selected method's currency.
+ const dataReady = !fetchingAmount && bookingAmountData != null && bookingAmountData.currency.toUpperCase() === amountCurrency.toUpperCase();
+
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
// split equally across both legs. This guarantees leg totals are consistent with the
// per-passenger breakdown rows and the overall reviewed total.
@@ -91,39 +90,33 @@ export default function PaymentPage() {
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
: 0;
- // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
- // in the booking's default currency (ETB) — they were computed and shown to the user on
- // the review page. But once a payment method with its own currency is selected (e.g.
- // Waafi/USD), the converted amount from the booking-amount API takes over so the user
- // sees the actual amount they'll be charged in that currency.
+ // reviewedTotalMinor is in display-currency minor units — matches what was shown on the review page.
+ // When a method with a different currency is selected, bookingAmountData gives the converted charge amount.
+ // When the method's currency matches displayCurrency (or no method selected), use reviewedTotal directly.
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
- const totalAmountDisplay = isConversionNeeded
- ? (bookingAmountData != null ? bookingAmountData.amount : null)
- : (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
- const totalAmount = isConversionNeeded
- ? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
- : (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
- const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
- // Show loading spinner while the converted amount is still in flight for a
- // currency-specific method; ETB methods always have the reviewed total instantly.
- const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
+ // When a method is selected: show spinner until dataReady, then show converted amount.
+ // When no method is selected: show the reviewed total in displayCurrency.
+ const totalAmountDisplay = selectedMethod
+ ? (dataReady ? bookingAmountData!.amount : null)
+ : (reviewedTotal != null ? reviewedTotal / 100 : null);
+ const totalAmount = selectedMethod && dataReady
+ ? Math.round(bookingAmountData!.amount * 100)
+ : (reviewedTotal ?? 0);
+ const confirmedCurrency = selectedMethod
+ ? (dataReady ? bookingAmountData!.currency : amountCurrency)
+ : displayCurrency;
+ const awaitingAmount = !!selectedMethod && !dataReady;
useEffect(() => {
- // Once a currency-specific payment method's converted amount has loaded, that's the
- // real charge amount and currency — store it as the paid amount. Otherwise fall back
- // to the reviewed ETB total shown on the review page.
- if (isConversionNeeded && bookingAmountData != null) {
- setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
- setPaidAmount(Math.round(bookingAmountData.amount * 100));
- } else if (reviewedTotal != null) {
- setCurrency('ETB');
+ if (selectedMethod && dataReady) {
+ setCurrency(bookingAmountData!.currency as 'ETB' | 'DJF' | 'USD');
+ setPaidAmount(Math.round(bookingAmountData!.amount * 100));
+ } else if (!selectedMethod && reviewedTotal != null) {
+ setCurrency(displayCurrency as 'ETB' | 'DJF' | 'USD');
setPaidAmount(reviewedTotal);
- } else if (bookingAmountData != null) {
- setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
- setPaidAmount(Math.round(bookingAmountData.amount * 100));
}
- }, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
+ }, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
const paymentMutation = useMutation({
mutationFn: async (data: any) => {
@@ -603,7 +596,7 @@ export default function PaymentPage() {
return (
{ setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
+ onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing || !method.enabled}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
From b4992546d12f21608b9a26f2f72f0a2ca0b64269 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 10:43:19 +0300
Subject: [PATCH 30/67] Build issue resolution
---
.../edr-passenger-api/src/modules/payments/payments.service.ts | 3 ++-
1 file changed, 2 insertions(+), 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 495edc17c..36d3fbd1d 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts
@@ -259,8 +259,9 @@ export class PaymentsService {
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
} else {
// Booking is in ETB — convert to the provider's settlement currency.
- chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
+ chargeAmount = await this.currencyService.convertMinorToChargeMajor(
booking.totalMinor,
+ 'ETB',
chargeCurrency,
);
}
From 6b84f22a91f5b9ae1c605c3fbfa1a387a0d40e13 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 10:48:36 +0300
Subject: [PATCH 31/67] Fix seat class name assignment to ensure consistency
with fare engine resolution
---
.../edr-passenger-api/src/modules/search/search.service.ts | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts
index 9f9fb7bdc..340d9fac9 100644
--- a/apps/edr-passenger-api/src/modules/search/search.service.ts
+++ b/apps/edr-passenger-api/src/modules/search/search.service.ts
@@ -704,7 +704,12 @@ export class SearchService {
scheduleId: schedule.id,
});
return {
- seatClassName: fare.seatClassName,
+ // Use the input seat class name (sc.name) so it always matches what
+ // buildCoachTypeDetails looks up via coachType.seatClasses. The fare
+ // engine may resolve a nationality-specific variant (nationalitySeatClass)
+ // whose name differs from sc.name, which would cause the class to be
+ // silently dropped from coachTypes and show N/A on the results page.
+ seatClassName: sc.name,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: fare.totalInBillingCurrency,
From b71a53c4a98eec804907c16279222c764ecb61e6 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 15:04:03 +0000
Subject: [PATCH 32/67] fix(warehouse): show booking reference instead of UUID
in inventory table
The inventory table's Booking column rendered a truncated bookingId UUID; use
the bookingReference already attached to each row (fall back to the short UUID
only when absent). Keep the UUID in the tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../src/components/warehouses/WarehouseInventoryTable.tsx | 8 ++++----
apps/edr-freight-web/backoffice/src/types/warehouse.ts | 4 ++++
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
index de262767f..060dc600e 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx
@@ -179,17 +179,17 @@ export function WarehouseInventoryTable({
{selectable && (
onToggleSelect?.(item.id)}
/>
)}
- {item.bookingId ? (
-
+ {item.bookingReference || item.booking?.reference || item.bookingId ? (
+
- {item.bookingId.slice(0, 8)}...
+ {item.bookingReference ?? item.booking?.reference ?? `${item.bookingId?.slice(0, 8)}...`}
) : (
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 7a88b136e..348646dbb 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -217,6 +217,10 @@ export interface WarehouseInventoryItem {
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
booking?: InventoryBookingRef | null;
+ /** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
+ bookingReference?: string | null;
+ bookingStatus?: string | null;
+ customerName?: string | null;
}
/** Slim booking shape returned alongside inventory for the loading queue. */
From fd4c4d630faa59cad5b2b9dc86e295474b362bb5 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 07:49:28 +0000
Subject: [PATCH 33/67] fix(warehouse): disable Receive-At-Warehouse once
booking is received
The Warehouse Information card kept the receive button active after the booking
already had an inventory record. Disable it (relabel "Received At Warehouse",
add a tooltip) when an inventory item exists.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../warehouses/WarehouseInfoCard.tsx | 26 +++++++++++++------
1 file changed, 18 insertions(+), 8 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
index e28513821..6bda9e88d 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
+import { Badge, Button, Card, Divider, Group, Stack, Text, Tooltip } from '@mantine/core';
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -126,14 +126,24 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
>
)}
- }
- onClick={() => setModalOpen(true)}
- fullWidth
+
- Receive At Warehouse
-
+ {/* span wrapper so the tooltip still fires on the disabled button */}
+
+ }
+ onClick={() => setModalOpen(true)}
+ fullWidth
+ disabled={Boolean(latest)}
+ >
+ {latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
+
+
+
Date: Wed, 15 Jul 2026 09:13:02 +0000
Subject: [PATCH 34/67] remove reopen delay minutes from global rules and
update related types
- Removed the field from and related components.
- Updated to reflect the removal of the reopen delay input field.
- Modified to include new train number fields: and .
- Added interface to manage active schedules with trade direction.
- Introduced interface to track wagon shortages in bookings.
- Updated logic to ensure consistent UI state representation.
- Created migrations to drop the column and add and columns to the table.
- Added tests for the new booking window display logic and wagon planning functionality.
---
.../2190000000000-DropReopenDelayMinutes.ts | 28 ++
.../2200000000000-TrainNumberPair.ts | 42 +++
.../bookings/entities/booking.entity.ts | 1 +
.../batch-window.util.spec.ts | 32 ++-
.../train-scheduling/batch-window.util.ts | 78 +++++-
.../booking-batch.service.spec.ts | 36 ++-
.../train-scheduling/booking-batch.service.ts | 96 ++++++-
.../train-scheduling/booking-window.config.ts | 2 -
.../booking-window.service.spec.ts | 38 ++-
.../booking-window.service.ts | 51 +++-
...pdate-train-scheduling-global-rules.dto.ts | 7 -
.../train-scheduling-global-rules.entity.ts | 4 -
.../train-scheduling/fleet-plan.util.spec.ts | 29 ++
.../train-scheduling/fleet-plan.util.ts | 21 ++
.../train-scheduling.service.ts | 131 ++++++++-
.../wagon-plan-flex.util.spec.ts | 133 +++++++++
.../train-scheduling/wagon-plan-flex.util.ts | 66 ++++-
.../src/modules/trains/dto/build-train.dto.ts | 17 ++
.../modules/trains/entities/train.entity.ts | 8 +
.../modules/trains/train-builder.service.ts | 91 +++++-
.../contracts/GlUpcomingWindowsSection.tsx | 80 +++---
.../trainBuilder/BuildTrainModal.tsx | 45 +++
.../components/trainBuilder/trainStatus.ts | 16 ++
.../ScheduleWorkspacePanel.tsx | 157 ++++++++++-
.../trainBuilder/TrainBuilderDetailPage.tsx | 30 +-
.../trainBuilder/TrainBuilderListPage.tsx | 34 ++-
.../TrainScheduleV2DetailPage.tsx | 40 ++-
.../TrainScheduleV2ListPage.tsx | 38 ++-
.../TrainSchedulingGlobalRulesPage.tsx | 12 -
.../src/services/trainBuilder.service.ts | 26 +-
.../backoffice/src/types/trainScheduling.ts | 16 +-
.../src/utils/bookingWindowDisplay.test.ts | 264 ++++++++++++++++++
.../components/UpcomingWindowsSection.tsx | 114 ++++----
.../ContractBookingWindowsSection.tsx | 82 +++---
packages/types/src/freight/index.ts | 2 +
.../src/components/data-table/table.tsx | 14 +-
.../src/components/data-table/types.ts | 4 +
packages/ui-common/src/index.ts | 7 +
.../src/lib/booking-window-display.ts | 108 +++++++
39 files changed, 1731 insertions(+), 269 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts
create mode 100644 apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
create mode 100644 apps/edr-freight-web/backoffice/src/utils/bookingWindowDisplay.test.ts
create mode 100644 packages/ui-common/src/lib/booking-window-display.ts
diff --git a/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts
new file mode 100644
index 000000000..5a667e2e4
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts
@@ -0,0 +1,28 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+/**
+ * Drop the unused reopen-delay knob from the global rules.
+ *
+ * The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
+ * reopens as soon as its payment phase settles, so the real gap between a cycle
+ * closing and reopening is doc review + payment — nothing else. The per-schedule
+ * `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
+ * creation so the batch board keeps projecting the cycles the customer was shown.
+ */
+export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
+ name = "DropReopenDelayMinutes2190000000000";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.train_scheduling_global_rules
+ DROP COLUMN IF EXISTS reopen_delay_minutes;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.train_scheduling_global_rules
+ ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts
new file mode 100644
index 000000000..65a3d3cda
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts
@@ -0,0 +1,42 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Every built train owns a fixed pair of run numbers, typed at build time:
+ * an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
+ * Scheduling copies the route-direction-matched number onto the schedule at
+ * creation; legacy trains with a null pair keep dispatch-time pool assignment.
+ *
+ * NOTE: the shared dev DB has no applied migration history, so this is also
+ * hand-applied there. IF NOT EXISTS keeps that idempotent.
+ */
+export class TrainNumberPair2200000000000 implements MigrationInterface {
+ name = 'TrainNumberPair2200000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.trains
+ ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
+ ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
+ ON freight.trains (import_train_number)
+ WHERE import_train_number IS NOT NULL;
+ `);
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
+ ON freight.trains (export_train_number)
+ WHERE export_train_number IS NOT NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
+ await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
+ await queryRunner.query(`
+ ALTER TABLE freight.trains
+ DROP COLUMN IF EXISTS export_train_number,
+ DROP COLUMN IF EXISTS import_train_number;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 4cc15854e..79d15069b 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [
SchedulingStatus.Eligible,
SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched,
+ SchedulingStatus.WaitingForWagon,
] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
index ba0995679..e913af6b7 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts
@@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours',
});
it('caps the close at departure', () => {
- // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
- // past the 06 Jul 08:00 departure → clamped to departure.
+ // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
+ // EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
+ // departure → clamped to departure.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
- { ...bounded, windowDurationHours: 24 },
+ { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
now,
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
+ it('desk close hour cuts the window short (duration never outlives the desk)', () => {
+ // Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
+ // day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
+ const now = new Date('2026-07-05T09:00:00.000Z');
+ const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
+ expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
+ });
+
describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
@@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
- // 3h long, reopen 90m later.
+ // 3h long, reopen gap (doc review + payment) 90m.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
- reopenDelayMinutes: 90,
+ reopenGapMinutes: 90,
exportBookingLeadHours: 24,
};
@@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
- it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
+ it('import: reopens after the doc-review + payment gap while inside office hours', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
+ it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
+ const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
+ const departure = new Date('2026-06-08T11:00:00.000Z');
+ const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
+ // Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
+ expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
+ expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
+ // Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
+ expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
+ });
+
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
index 0fdacc572..a1dfe61f4 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts
@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
+/**
+ * The desk-close instant of the office window containing `opensAt`; null for a
+ * round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
+ * EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
+ * `opensAt` sits in the evening half, closeHour the same day when it sits in the
+ * after-midnight half.
+ */
+export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
+ if (isRoundTheClock(hours)) return null;
+ const { hour, minute } = eatParts(opensAt);
+ const openMinutes = hour * 60 + minute;
+ if (
+ hours.windowOpenHour > hours.windowCloseHour &&
+ openMinutes >= hours.windowOpenHour * 60
+ ) {
+ return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
+ }
+ return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
+}
+
+/**
+ * Cap a window close at the desk-close hour that follows its open: the office
+ * hours end a running window early rather than letting the duration outlive the
+ * desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A
+ * round-the-clock desk never caps; a desk-close at/before the open (degenerate
+ * config) is ignored so the window is never clamped to zero length here.
+ */
+export function clampCloseToOfficeHours(
+ opensAt: Date,
+ closesAt: Date,
+ hours: OfficeHours,
+): Date {
+ const deskClose = officeCloseAfter(opensAt, hours);
+ if (
+ deskClose != null &&
+ deskClose.getTime() > opensAt.getTime() &&
+ closesAt.getTime() > deskClose.getTime()
+ ) {
+ return deskClose;
+ }
+ return closesAt;
+}
+
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
* • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next morning
*
- * `windowDurationHours` extends from that open, capped at departure.
+ * `windowDurationHours` extends from that open, capped at the desk close hour
+ * and at departure.
*/
export function computeImportWindowTimes(
departure: Date,
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
+ closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
+ windowOpenHour: cfg.windowOpenHour,
+ windowCloseHour: cfg.windowCloseHour,
+ });
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
// ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the
-// train_scheduling_global_rules config (window open hour, lead days, duration,
-// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
-// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
-// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
+// schedule's frozen window rule (open/close hour, lead days, duration, reopen
+// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
+// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
+// capped at the desk close, reopens after the gap until departure). Export shows
+// the single FCFS lead window.
// ---------------------------------------------------------------------------
/** A board window carries an EAT calendar date in addition to the slot times. */
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
- /** Gap between a cycle's close and its reopen (doc review + payment minutes). */
- reopenDelayMinutes: number;
+ /**
+ * Gap between a cycle's close and its reopen — always doc review + payment
+ * minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
+ */
+ reopenGapMinutes: number;
exportBookingLeadHours: number;
}
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
- * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
- * after each close, on the same booking day, until departure. This mirrors
- * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
- * exact windows the engine runs.
+ * for `windowDurationHours` (cut short by the desk close hour); if the train isn't
+ * full it reopens `reopenGapMinutes` (doc review + payment) after each close,
+ * honouring office hours, until departure. This mirrors `computeImportWindowTimes`
+ * + `concludeCycle`'s reopen math so the board shows the exact windows the engine
+ * runs.
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
- const reopenMs = cfg.reopenDelayMinutes * 60_000;
+ const reopenMs = cfg.reopenGapMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
+ closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
index c8e254141..c37430121 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
+ previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
};
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
+ // No shortage by default — paid bookings link as before.
+ previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
- reopenDelayMinutes: 90,
}),
};
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
});
+ it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
+ trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 1,
+ wagonsAvailable: 0,
+ wagonsShort: 1,
+ });
+
+ await service.ensurePaidBookingAllocated(bookingId);
+
+ // Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
+ expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
+ expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
+ expect(dataSource.getRepository().update).toHaveBeenCalledWith(
+ bookingId,
+ expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
+ );
+ });
+
+ it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
+ bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
+ { ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
+ ]);
+
+ await service.reconcilePaidUnlinked(scheduleId);
+
+ expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
+ expect(
+ trainSchedulingService.previewPaidBookingWagonShortage,
+ ).not.toHaveBeenCalled();
+ });
+
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index e51d4ad23..82782446a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
+ if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
+ // Held on purpose (paid, no wagon free) — the cron must not undo it.
+ if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
+ if (await this.holdIfWagonShort(scheduleId, booking)) continue;
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
- reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
+ // Frozen doc-review + payment sum; legacy rows fall back to the live sum.
+ reopenGapMinutes: num(
+ s.ruleReopenDelayMinutes,
+ liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
+ ),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
done.add(booking.id);
if (isPaid(booking)) {
- await this.allocate(scheduleId, booking, "paid");
+ if (!(await this.holdIfWagonShort(scheduleId, booking))) {
+ await this.allocate(scheduleId, booking, "paid");
+ }
anySettled = true;
} else if (isExpired(booking)) {
await this.expire(booking);
@@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit {
);
}
+ /**
+ * Conclude-time retry: promote whatever still fits from the route-day waiting
+ * list, opening fresh pay windows. Returns how many commercial units got
+ * reserved — corridor-wide, since the fill is day-level and may reserve onto a
+ * sibling train; the caller must check `hasLiveReservations` for its OWN
+ * schedule before deciding to stay in PAYMENT.
+ */
+ async fillFromWaitingList(scheduleId: string): Promise {
+ return this.withScheduleLock(scheduleId, async () => {
+ let promoted = 0;
+ for (let round = 0; round < 10; round += 1) {
+ const reservedThisRound = await this.topUpFill(scheduleId);
+ if (reservedThisRound <= 0) break;
+ promoted += reservedThisRound;
+ await this.extendPaymentPhaseForTopUp(scheduleId);
+ }
+ if (promoted > 0) {
+ this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
+ }
+ return promoted;
+ });
+ }
+
/**
* Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled.
@@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" });
- await this.allocate(booking.trainScheduleId, booking, "paid");
+ if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
+ await this.allocate(booking.trainScheduleId, booking, "paid");
+ }
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
- schedulingStatus: "ELIGIBLE",
+ // A paid booking still hunting for a wagon keeps its flag through the
+ // move — it only clears when wagons are actually assigned.
+ schedulingStatus:
+ booking.schedulingStatus === "WAITING_FOR_WAGON"
+ ? "WAITING_FOR_WAGON"
+ : "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
+ /**
+ * Fleet preflight shared by every single-booking paid-allocation path: when
+ * no wagon of the booking's required type is free, hold it OUT of the train
+ * instead of linking — it stays PAID + unlinked in the (route, day) pool,
+ * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
+ * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
+ * when the booking was held. Consolidated pairs are exempt (the shared wagon
+ * is both-or-neither and settles atomically in settleReserved).
+ */
+ private async holdIfWagonShort(
+ scheduleId: string,
+ booking: Booking,
+ ): Promise {
+ if (booking.consolidationPartnerId) return false;
+ const shortage =
+ await this.trainSchedulingService.previewPaidBookingWagonShortage(
+ scheduleId,
+ booking.id,
+ );
+ if (!shortage) return false;
+
+ await this.dataSource.getRepository(Booking).update(booking.id, {
+ status: "PAID",
+ paymentStatus: "PAID",
+ schedulingStatus: "WAITING_FOR_WAGON",
+ paymentDeadline: null,
+ selectedForBatchAt: null,
+ } as never);
+ // Payment landed — record it even though nothing boards yet. The wagon
+ // milestone stays pending until staff assign one.
+ void this.completeTrackingMilestones(booking.id, [
+ "FREIGHT_PAYMENT_PENDING",
+ "FREIGHT_PAYMENT_SETTLED",
+ ]);
+ this.logger.warn(
+ `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
+ `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
+ `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
+ `Held in the day pool for manual placement.`,
+ );
+ this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
+ return true;
+ }
+
private async allocate(
scheduleId: string,
booking: Booking,
@@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
`landed; allocating on schedule ${paidScheduleId} instead`,
);
- await this.allocate(paidScheduleId, fresh, "paid");
+ if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
+ await this.allocate(paidScheduleId, fresh, "paid");
+ }
return;
}
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
index 25d569171..7128bd705 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts
@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
paymentWindowMinutes: number;
- /** Delay after window close before reopening when the train is not full. */
- reopenDelayMinutes: number;
}
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
index 72229cfd6..9393c520f 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock;
+ fillFromWaitingList: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
- reopenDelayMinutes: 0,
};
const baseSchedule = (over: Partial): TrainSchedule =>
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
+ // No waiting booking fits by default, so conclude proceeds to reopen/DONE.
+ fillFromWaitingList: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
+ // The batch reserved someone (live reservations exist) → real PAYMENT phase.
+ batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
@@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => {
});
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
+ batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
@@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => {
expect(s.windowPhase).toBe('PAYMENT');
});
+ it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
+ // Default hasLiveReservations=false: the batch reserved nobody. Waiting a
+ // full payment window with the desk shut would serve no one — the cycle
+ // concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
+ const s = baseSchedule({
+ windowPhase: 'DOC_REVIEW',
+ docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
+ });
+ const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
+ expect(advanced).toBe(true);
+ expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
+ expect(s.windowPhase).toBe('PRE_WINDOW');
+ expect(s.windowOpensAt).not.toBeNull();
+ });
+
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
const s = baseSchedule({
windowPhase: 'PAYMENT',
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
});
+ it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
+ batch.isScheduleFull.mockResolvedValue(false);
+ batch.fillFromWaitingList.mockResolvedValue(2);
+ batch.hasLiveReservations.mockResolvedValue(true);
+ const s = baseSchedule({
+ windowPhase: 'PAYMENT',
+ scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
+ });
+ const now = new Date('2026-07-01T02:30:05.000Z');
+ await concludeCycle(s, now);
+ expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
+ expect(s.windowPhase).toBe('PAYMENT');
+ // Fresh pay window from `now`, not a reopen.
+ expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
+ });
+
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
index d3405c951..f728afe6a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts
@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
-import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
+import {
+ clampCloseToOfficeHours,
+ eatDay,
+ nextCycleOpensAt,
+ type OfficeHours,
+} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
@@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit {
// (or allocating government) — skipped automatically for everyone who fits
// is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay(routeDay);
+ // Batch reserved nobody (empty pool, or it allocated without pay windows):
+ // a PAYMENT phase with nobody to pay is a dead hour with the window shut.
+ // Conclude straight away — full → DONE, otherwise reopen per office hours.
+ if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
+ this.logger.log(
+ `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
+ `skipping the empty payment phase and concluding the cycle`,
+ );
+ await this.concludeCycle(schedule, cfg, now);
+ return true;
+ }
this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`,
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
return false;
}
- /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
+ /**
+ * After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
+ * window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
+ */
private async concludeCycle(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
}
+ // The window reopens only once the waiting list is exhausted: a booking can
+ // still reach the pool mid-payment (late doc accept, consolidation partner),
+ // so retry the batch before reopening. Anything that fits gets a fresh pay
+ // window and the cycle stays in PAYMENT; check live reservations on THIS
+ // schedule because the day-level fill may have reserved onto a sibling.
+ // Waiting bookings that fit no train stay pooled and the window reopens.
+ const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
+ if (
+ promoted > 0 &&
+ (await this.bookingBatchService.hasLiveReservations(schedule.id))
+ ) {
+ let paymentPhaseEndsAt = new Date(
+ now.getTime() + cfg.paymentWindowMinutes * 60_000,
+ );
+ if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
+ paymentPhaseEndsAt = schedule.scheduledDepartureDate;
+ }
+ await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
+ this.logger.log(
+ `[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
+ `fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
+ );
+ return;
+ }
+
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
);
+ // Office hours end a running window early: never let the duration outlive
+ // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
+ nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
index 0e252240b..2948b874d 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts
@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
-
- @ApiPropertyOptional({ example: 90 })
- @IsOptional()
- @Type(() => Number)
- @IsInt()
- @Min(1)
- reopenDelayMinutes?: number;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
index ffa42fd7e..729063599 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts
@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
-
- /** Delay after window close before the window reopens when the train is not yet full. */
- @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
- reopenDelayMinutes!: number;
}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
index 54bf7a181..ff685d30c 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts
@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
+ it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
+ const warnings = summarizeFleetWarnings(
+ [],
+ [
+ {
+ id: 'b1',
+ reference: 'BKG-1',
+ reason: 'No available NW6 wagon at the yard',
+ shortage: {
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 2,
+ wagonsAvailable: 1,
+ wagonsShort: 1,
+ },
+ },
+ ],
+ );
+
+ expect(
+ warnings.some(
+ (w) =>
+ w.includes('BKG-1') &&
+ w.includes('2 × NW6') &&
+ w.includes('only 1 available') &&
+ w.includes('short 1'),
+ ),
+ ).toBe(true);
+ });
+
it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', {
bookingContainers: [
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
index 6173da6b1..9cca4d213 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
shortfall: number;
};
+/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
+export type BookingWagonShortage = {
+ /** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
+ wagonTypeCodes: string;
+ wagonsNeeded: number;
+ wagonsAvailable: number;
+ wagonsShort: number;
+};
+
export type DeferredBookingRow = {
id: string;
reference: string;
reason: string;
+ /** Set when the deferral is a fleet-stock shortage (absent for config issues). */
+ shortage?: BookingWagonShortage | null;
};
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
@@ -156,6 +167,16 @@ export function summarizeFleetWarnings(
);
}
+ // Name the bookings the shortage actually hits, with their own per-type counts,
+ // so staff know WHAT is held out — not just that the pool is short overall.
+ for (const row of deferred) {
+ if (!row.shortage) continue;
+ warnings.push(
+ `Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
+ `only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
+ );
+ }
+
if (deferred.length) {
warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
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 a0b1591f3..26de90298 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
@@ -99,6 +99,7 @@ import {
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
+ type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
- reopenDelayMinutes:
- schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
+ /** Structured fleet shortage when the block is missing wagons (null otherwise). */
+ shortage: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
@@ -613,7 +614,6 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
- if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
@@ -702,7 +702,6 @@ export class TrainSchedulingService {
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
- reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -929,7 +928,6 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
- reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
};
}
@@ -1079,6 +1077,20 @@ export class TrainSchedulingService {
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
+ // Direction-matched fixed number from the built train's typed pair.
+ // Legacy locomotive-picked schedules keep dispatch-time pool assignment
+ // (assignTrainNumber is idempotent, so both paths compose).
+ const pairTrainNumber = builtTrain
+ ? (direction === 'IMPORT'
+ ? builtTrain.importTrainNumber
+ : builtTrain.exportTrainNumber) ?? null
+ : null;
+ if (builtTrain && !pairTrainNumber) {
+ scheduleWarnings.push(
+ `Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
+ );
+ }
+
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotives,
@@ -1174,6 +1186,7 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
+ trainNumber: pairTrainNumber ?? undefined,
maxWagons,
...windowFields,
}),
@@ -2684,7 +2697,23 @@ export class TrainSchedulingService {
manager: EntityManager,
schedule: TrainSchedule,
): Promise {
- if (schedule.trainNumber) return schedule.trainNumber;
+ if (schedule.trainNumber) {
+ // Creation-assigned pair number: two live runs may never share a number,
+ // so block dispatch while another DISPATCHED schedule still carries it.
+ const clash = await manager
+ .getRepository(TrainSchedule)
+ .createQueryBuilder('s')
+ .where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
+ .andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
+ .andWhere('s.id != :id', { id: schedule.id })
+ .getOne();
+ if (clash) {
+ throw new ConflictException(
+ `Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
+ );
+ }
+ return schedule.trainNumber;
+ }
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
@@ -2705,17 +2734,38 @@ export class TrainSchedulingService {
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
+ // DRAFT/SCHEDULED are included because pair numbers are now assigned at
+ // creation and must be invisible to pool picks.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
- .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
+ .where('schedule.status IN (:...statuses)', {
+ statuses: [
+ TrainScheduleStatusEnum.Draft,
+ TrainScheduleStatusEnum.Scheduled,
+ TrainScheduleStatusEnum.Dispatched,
+ ],
+ })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
- const usedNumbers = activeNumbered
- .map((s) => s.trainNumber)
- .filter((n): n is string => Boolean(n));
+ // Every typed train pair is reserved for its train — the pool may never
+ // hand one out, even when that train has no active schedule right now.
+ const pairRows: { n: string }[] = await manager.query(
+ `SELECT import_train_number AS n FROM freight.trains
+ WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
+ UNION
+ SELECT export_train_number FROM freight.trains
+ WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
+ );
+
+ const usedNumbers = [
+ ...activeNumbered
+ .map((s) => s.trainNumber)
+ .filter((n): n is string => Boolean(n)),
+ ...pairRows.map((row) => row.n),
+ ];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
@@ -4624,6 +4674,8 @@ export class TrainSchedulingService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
+ importTrainNumber: train.importTrainNumber ?? null,
+ exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
@@ -5522,7 +5574,7 @@ export class TrainSchedulingService {
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
- // sum, as reopenDelayMinutes), so the editor prefills them from live config.
+ // sum, as the frozen reopen gap), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -5530,7 +5582,6 @@ export class TrainSchedulingService {
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
- reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
@@ -6158,6 +6209,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
+ shortage: BookingWagonShortage | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
@@ -6166,6 +6218,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
+ shortage: null,
};
}
@@ -6186,6 +6239,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
+ shortage: null,
};
}
@@ -6230,6 +6284,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
+ shortage: null,
};
}
@@ -6240,6 +6295,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
+ shortage: null,
};
}
@@ -6259,6 +6315,16 @@ export class TrainSchedulingService {
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
+ shortage:
+ deferred?.shortage ??
+ (yardShortfall
+ ? {
+ wagonTypeCodes: requiredWagonTypeCode,
+ wagonsNeeded: wagonsRequired,
+ wagonsAvailable: yardWagonsAvailable,
+ wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
+ }
+ : null),
};
}
@@ -6277,6 +6343,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
+ shortage: null,
};
}
}
@@ -6287,9 +6354,49 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: true,
blockReason: null,
+ shortage: null,
};
}
+ /**
+ * Fleet-shortage preflight for a PAID booking targeting a schedule: the
+ * structured per-type shortage this booking would hit if placed on top of the
+ * schedule's current wagon assignments, or null when it fits (or is blocked
+ * by something other than missing wagons — those keep the legacy link-then-
+ * fix-manually path).
+ */
+ async previewPaidBookingWagonShortage(
+ scheduleId: string,
+ bookingId: string,
+ ): Promise {
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ if (!schedule?.trainSet?.locomotive) return null;
+ if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
+
+ const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
+ if (!booking) return null;
+
+ const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
+ const fleetCounts = await this.countFleetAvailability(
+ schedule.originStationId,
+ scheduleId,
+ );
+ const fleetByTypeId = new Map(
+ fleetCounts.map((row) => [
+ row.wagonTypeId,
+ { code: row.wagonTypeCode, available: row.available },
+ ]),
+ );
+
+ const assignability = await this.previewUnassignedBookingAssignability(
+ schedule,
+ wagonAssignedIds,
+ booking,
+ fleetByTypeId,
+ );
+ return assignability.shortage;
+ }
+
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: {
status: string;
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
new file mode 100644
index 000000000..157666f55
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
@@ -0,0 +1,133 @@
+import { Booking } from '../bookings/entities/booking.entity';
+import { WagonType } from '../wagon-types/entities/wagon-type.entity';
+import { planWagonsWithStock } from './wagon-plan-flex.util';
+
+const nw6: WagonType = {
+ id: 'wt-nw6',
+ code: 'NW6',
+ name: 'Flat Wagon',
+ capacityTons: 70,
+ lengthMeters: 14,
+ supportedLoadTypes: ['CONTAINER'],
+ isActive: true,
+ supportsContainer: true,
+} as WagonType;
+
+const cw3: WagonType = {
+ id: 'wt-cw3',
+ code: 'CW3',
+ name: 'Covered Wagon',
+ capacityTons: 60,
+ lengthMeters: 14,
+ supportedLoadTypes: ['BULK'],
+ isActive: true,
+ supportsContainer: false,
+} as WagonType;
+
+const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
+ ({
+ id,
+ reference: id,
+ freightType: 'CONTAINER',
+ cargoTotalWeightVgm: quantity * 25,
+ bookingContainers: [
+ {
+ id: `${id}-line-0`,
+ containerTypeId: 'ct-1',
+ quantity,
+ wagonsRequired,
+ vgmPerUnitTons: 25,
+ },
+ ],
+ }) as Booking;
+
+describe('planWagonsWithStock — shortage detail', () => {
+ it('defers with a structured per-type shortage when container stock runs out', () => {
+ const result = planWagonsWithStock({
+ bookings: [containerBooking('BKG-1', 2, 1)],
+ allowed: {
+ byContainerTypeId: new Map([['ct-1', [nw6]]]),
+ byCargoTypeId: new Map(),
+ },
+ stock: {
+ mode: 'YARD',
+ remainingByTypeId: new Map([[nw6.id, 0]]),
+ codesByTypeId: new Map([[nw6.id, nw6.code]]),
+ },
+ });
+
+ expect(result.fitting).toHaveLength(0);
+ expect(result.deferred).toHaveLength(1);
+ const row = result.deferred[0]!;
+ expect(row.reference).toBe('BKG-1');
+ expect(row.reason).toContain('No available NW6 wagon at the yard');
+ expect(row.reason).toContain('short 1');
+ expect(row.shortage).toEqual({
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 1,
+ wagonsAvailable: 0,
+ wagonsShort: 1,
+ });
+ });
+
+ it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
+ // Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
+ // the shortage reports 1 available / 1 short.
+ const fortyFooter = containerBooking('BKG-2', 2, 2);
+ fortyFooter.bookingContainers![0]!.containerType = {
+ code: '40GP',
+ sizeFt: 40,
+ wagonsPerUnit: 1,
+ } as never;
+ const result = planWagonsWithStock({
+ bookings: [fortyFooter],
+ allowed: {
+ byContainerTypeId: new Map([['ct-1', [nw6]]]),
+ byCargoTypeId: new Map(),
+ },
+ stock: {
+ mode: 'YARD',
+ remainingByTypeId: new Map([[nw6.id, 1]]),
+ codesByTypeId: new Map([[nw6.id, nw6.code]]),
+ },
+ });
+
+ expect(result.deferred).toHaveLength(1);
+ expect(result.deferred[0]?.shortage).toEqual({
+ wagonTypeCodes: 'NW6',
+ wagonsNeeded: 2,
+ wagonsAvailable: 1,
+ wagonsShort: 1,
+ });
+ // The rolled-back wagon is plannable again for later bookings.
+ expect(result.plan).toHaveLength(0);
+ });
+
+ it('leaves shortage unset for configuration problems', () => {
+ const bulkBooking = {
+ id: 'BKG-3',
+ reference: 'BKG-3',
+ freightType: 'BULK',
+ cargoTotalWeightVgm: 40,
+ cargoTypeId: 'cargo-1',
+ cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
+ bookingContainers: [],
+ } as unknown as Booking;
+
+ const result = planWagonsWithStock({
+ bookings: [bulkBooking],
+ allowed: {
+ byContainerTypeId: new Map(),
+ byCargoTypeId: new Map(), // no wagon types configured → config issue
+ },
+ stock: {
+ mode: 'YARD',
+ remainingByTypeId: new Map([[cw3.id, 5]]),
+ codesByTypeId: new Map([[cw3.id, cw3.code]]),
+ },
+ });
+
+ expect(result.configIssues).toHaveLength(1);
+ expect(result.deferred[0]?.shortage).toBeNull();
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
index b4b8657ba..ad4c29aa1 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
@@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
-import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
+import {
+ sortBookingsForScheduling,
+ type BookingWagonShortage,
+ type DeferredBookingRow,
+} from './fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
+ containerWagonsForLines,
expandBookingContainerUnits,
roundTons,
tareTonsOf,
@@ -55,7 +60,12 @@ type OpenSlot = {
freeCapacityTons: number;
};
-type PlacementProblem = { kind: 'config' | 'stock'; message: string };
+type PlacementProblem = {
+ kind: 'config' | 'stock';
+ message: string;
+ /** Wagon types the failing placement could have used (stock problems only). */
+ candidates?: WagonType[];
+};
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
sequenceNo: 0, // stamped at the end
@@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
slotLoadType: kind,
});
+/**
+ * Booking-level shortage against the wagon types the failing placement could
+ * use: wagons the whole booking needs vs stock left for those types. Container
+ * counts are TEU-packed per booking; bulk divides by the largest candidate.
+ */
+const shortageFor = (
+ booking: Booking,
+ candidates: WagonType[],
+ remaining: Map,
+): BookingWagonShortage => {
+ const wagonsNeeded =
+ booking.freightType === 'BULK'
+ ? Math.max(
+ 1,
+ Math.ceil(
+ Number(booking.cargoTotalWeightVgm ?? 0) /
+ Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
+ ),
+ )
+ : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
+ const wagonsAvailable = candidates.reduce(
+ (sum, wt) => sum + (remaining.get(wt.id) ?? 0),
+ 0,
+ );
+ return {
+ wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
+ wagonsNeeded,
+ wagonsAvailable,
+ wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable),
+ };
+};
+
const addAllocation = (
slot: WagonPlanSlot,
bookingId: string,
@@ -120,7 +162,9 @@ export function planWagonsWithStock(params: {
cargoTypeId: string | null,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
- if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
+ if (!inStock.length) {
+ return { kind: 'stock', message: noStockMessage(candidates), candidates };
+ }
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
const chosen = [...inStock].sort((a, b) =>
@@ -271,7 +315,21 @@ export function planWagonsWithStock(params: {
});
if (problem.kind === 'config') configIssues.add(problem.message);
- deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
+ // remaining is rolled back here, so the shortage counts the stock this
+ // booking actually saw — not what its own partial placement consumed.
+ const shortage =
+ problem.kind === 'stock' && problem.candidates?.length
+ ? shortageFor(booking, problem.candidates, remaining)
+ : null;
+ deferred.push({
+ id: booking.id,
+ reference: booking.reference,
+ reason: shortage
+ ? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
+ `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})`
+ : problem.message,
+ shortage,
+ });
}
return {
diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
index c44be8786..b4548edbb 100644
--- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
+++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
IsUUID,
+ Matches,
MaxLength,
} from 'class-validator';
@@ -14,6 +15,22 @@ export class BuildTrainDto {
@MaxLength(32)
code!: string;
+ @ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
+ @IsString()
+ @MaxLength(20)
+ @Matches(/^\d*[13579]$/, {
+ message: 'Export train number must be numeric and odd (e.g. 8001)',
+ })
+ exportTrainNumber!: string;
+
+ @ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' })
+ @IsString()
+ @MaxLength(20)
+ @Matches(/^\d*[02468]$/, {
+ message: 'Import train number must be numeric and even (e.g. 8002)',
+ })
+ importTrainNumber!: string;
+
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID()
currentYardId!: string;
diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
index 078b07a20..5b26c696a 100644
--- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
+++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
@@ -32,6 +32,14 @@ export class Train extends BaseEntity {
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
+ /** Fixed IMPORT (even) run number typed at build time; unique via partial index. */
+ @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
+ importTrainNumber!: string | null;
+
+ /** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */
+ @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
+ exportTrainNumber!: string | null;
+
// --- new required fields ---
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
trainNumber?: string;
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 16ff29d38..d385a424c 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
@@ -27,6 +27,15 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
+/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
+export interface ActiveScheduleRef {
+ id: string;
+ status: string;
+ reference: string | null;
+ direction: string | null;
+ trainNumber: string | null;
+}
+
/**
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a
@@ -56,6 +65,25 @@ export class TrainBuilderService {
throw new ConflictException(`Train code ${code} is already in use`);
}
+ // Friendly 409 before the partial unique indexes (the race-proof backstop):
+ // the typed pair may not collide with any train's pair or legacy number.
+ const importTrainNumber = dto.importTrainNumber.trim();
+ const exportTrainNumber = dto.exportTrainNumber.trim();
+ const numberClash: { code: string }[] = await manager.query(
+ `SELECT code FROM freight.trains
+ WHERE deleted_at IS NULL
+ AND (import_train_number IN ($1, $2)
+ OR export_train_number IN ($1, $2)
+ OR train_number IN ($1, $2))
+ LIMIT 1`,
+ [importTrainNumber, exportTrainNumber],
+ );
+ if (numberClash.length) {
+ throw new ConflictException(
+ `Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
+ );
+ }
+
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
@@ -76,6 +104,8 @@ export class TrainBuilderService {
status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined,
+ importTrainNumber,
+ exportTrainNumber,
}),
);
@@ -117,12 +147,42 @@ export class TrainBuilderService {
take,
});
+ const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
+
return {
- items: trains.map((train) => this.mapSummary(train)),
+ items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
+ /**
+ * One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
+ * else the earliest upcoming departure) — feeds the list's direction tint
+ * and in-use train number.
+ */
+ private async loadActiveScheduleByTrain(
+ trainIds: string[],
+ ): Promise> {
+ if (!trainIds.length) return new Map();
+ const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query(
+ `SELECT DISTINCT ON (tset.train_id)
+ tset.train_id AS "trainId",
+ ts.id,
+ ts.status,
+ ts.reference,
+ ts.direction,
+ ts.train_number AS "trainNumber"
+ FROM freight.train_schedules ts
+ JOIN freight.train_sets tset ON tset.id = ts.train_set_id
+ WHERE tset.train_id = ANY($1)
+ AND ts.deleted_at IS NULL
+ AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`,
+ [trainIds],
+ );
+ return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
+ }
+
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
@@ -139,17 +199,17 @@ export class TrainBuilderService {
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
- const schedules: { id: string; status: string; reference: string | null }[] =
- await this.dataSource.query(
- `SELECT ts.id, ts.status, ts.reference
- FROM freight.train_schedules ts
- JOIN freight.train_sets tset ON tset.id = ts.train_set_id
- WHERE tset.train_id = $1
- AND ts.deleted_at IS NULL
- AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
- ORDER BY ts.scheduled_departure_date ASC`,
- [id],
- );
+ const schedules: ActiveScheduleRef[] = await this.dataSource.query(
+ `SELECT ts.id, ts.status, ts.reference, ts.direction,
+ ts.train_number AS "trainNumber"
+ FROM freight.train_schedules ts
+ JOIN freight.train_sets tset ON tset.id = ts.train_set_id
+ WHERE tset.train_id = $1
+ AND ts.deleted_at IS NULL
+ AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
+ ORDER BY ts.scheduled_departure_date ASC`,
+ [id],
+ );
const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
@@ -212,6 +272,8 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
+ importTrainNumber: train.importTrainNumber ?? null,
+ exportTrainNumber: train.exportTrainNumber ?? null,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
@@ -407,7 +469,7 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals
- private mapSummary(train: Train) {
+ private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
@@ -421,6 +483,9 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
+ importTrainNumber: train.importTrainNumber ?? null,
+ exportTrainNumber: train.exportTrainNumber ?? null,
+ activeSchedule,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
index c35537d9e..ba9e01402 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx
@@ -17,7 +17,8 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
-import { CountdownTimer } from "@edr/ui-common";
+import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
+import type { BookingWindowUiKind } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
@@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string {
}
/**
- * The countdown for whichever phase the window is currently in, mirroring the
- * customer portal. `expiredText` names the NEXT step so a deadline that lapses
- * between refetches announces what comes next rather than the bare "Expired".
+ * The countdown for the window's UI state, mirroring the customer portal.
+ * Derived from the SAME state as the badge (`bookingWindowUiState`) so they
+ * can never contradict — a full train shows no ticking countdown.
+ * `expiredText` names the NEXT step so a deadline that lapses between
+ * refetches announces what comes next rather than the bare "Expired".
*/
+const COUNTDOWN_TEXT: Partial<
+ Record
+> = {
+ PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
+ OPEN: { label: "Closes in", expiredText: "Review starting…" },
+ DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
+ PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
+};
+
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
- switch (w.windowPhase) {
- case "PRE_WINDOW":
- return w.windowOpensAt
- ? {
- label: "Opens in",
- deadline: w.windowOpensAt,
- expiredText: "Opening now…",
- }
- : null;
- case "OPEN":
- return w.windowClosesAt
- ? {
- label: "Closes in",
- deadline: w.windowClosesAt,
- expiredText: "Review starting…",
- }
- : null;
- case "DOC_REVIEW":
- return w.docReviewEndsAt
- ? {
- label: "Doc review ends in",
- deadline: w.docReviewEndsAt,
- expiredText: "Payment starting…",
- }
- : null;
- case "PAYMENT":
- return w.paymentPhaseEndsAt
- ? {
- label: "Payment ends in",
- deadline: w.paymentPhaseEndsAt,
- expiredText: "Closing…",
- }
- : null;
- default:
- return null;
- }
+ const state = bookingWindowUiState(w);
+ const text = COUNTDOWN_TEXT[state.kind];
+ if (!state.countdownTo || !text) return null;
+ return { ...text, deadline: state.countdownTo };
}
+/** Badge label + Mantine color per UI state — same state the countdown uses. */
+const KIND_BADGE: Record = {
+ OPEN: { label: "Open now", color: "edr-green" },
+ FULL: { label: "Train full", color: "red" },
+ PRE_WINDOW: { label: "Opens soon", color: "yellow" },
+ DOC_REVIEW: { label: "Doc review", color: "gray" },
+ PAYMENT: { label: "Payment", color: "gray" },
+ CLOSED: { label: "Closed", color: "gray" },
+};
+
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w);
- const open = w.isOpenNow;
+ const state = bookingWindowUiState(w);
+ const badge = KIND_BADGE[state.kind];
+ const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
)}
- {open
- ? "Open now"
- : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
+ {badge.label}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
index 74f897728..6eb2ab9a8 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
@@ -26,6 +26,10 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
+// Run-number parity carries the trade direction: odd = export, even = import.
+const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
+const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
+
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
@@ -34,6 +38,8 @@ const parseError = (error: unknown, fallback: string) => {
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
+ const [exportTrainNumber, setExportTrainNumber] = useState("");
+ const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState([]);
@@ -57,6 +63,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
setCode("");
+ setExportTrainNumber("");
+ setImportTrainNumber("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
@@ -72,9 +80,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
});
return;
}
+ if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
+ toast({
+ title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
+ variant: "destructive",
+ });
+ return;
+ }
try {
const composition = await build.mutateAsync({
code: code.trim(),
+ exportTrainNumber: exportTrainNumber.trim(),
+ importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
@@ -126,6 +143,34 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100}
/>
+
+ setExportTrainNumber(e.currentTarget.value)}
+ maxLength={20}
+ error={
+ exportTrainNumber && !isOddNumber(exportTrainNumber)
+ ? "Must be numeric and odd"
+ : undefined
+ }
+ />
+ setImportTrainNumber(e.currentTarget.value)}
+ maxLength={20}
+ error={
+ importTrainNumber && !isEvenNumber(importTrainNumber)
+ ? "Must be numeric and even"
+ : undefined
+ }
+ />
+
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
+
+/** Badge color per trade direction (Mantine palette keys). */
+export const directionColor = (direction?: string | null): string =>
+ direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
+
+/** Row background tint for a train whose active schedule runs in `direction`. */
+export const directionRowStyle = (
+ direction?: string | null,
+): CSSProperties | undefined =>
+ direction === "IMPORT"
+ ? { backgroundColor: "var(--mantine-color-blue-0)" }
+ : direction === "EXPORT"
+ ? { backgroundColor: "var(--mantine-color-orange-0)" }
+ : undefined;
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
index 6a6ba09d9..2b944b7a3 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx
@@ -152,6 +152,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
+ const assignUnassigned = useMutation(
+ api.trainScheduling.assignUnassignedBooking.mutationOptions(),
+ );
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState(null);
const [moveTarget, setMoveTarget] = useState(null);
+ // Pool → pick a same-day schedule with free wagons and place the booking there.
+ const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
+ null,
+ );
+ const [poolTarget, setPoolTarget] = useState(null);
+
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
[targets, schedule.id],
);
+ // Every schedule departing on THIS train's day (EAT) — a paid booking waiting
+ // for a wagon may board any of them, so staff pick whichever has wagons free.
+ const eatDayOf = (iso: string) =>
+ new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
+ const sameDayOptions = useMemo(() => {
+ const day = eatDayOf(schedule.scheduledDepartureDate);
+ return (targets ?? [])
+ .filter((s) => eatDayOf(s.scheduleDate) === day)
+ .map((s) => ({
+ value: s.id,
+ label: `${s.id === schedule.id ? "This train · " : ""}${
+ s.routeName ?? `${s.origin} → ${s.destination}`
+ } · ${s.remainingWagons}/${s.maxWagons} wagons free`,
+ }));
+ }, [targets, schedule.id, schedule.scheduledDepartureDate]);
+
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
@@ -288,6 +313,36 @@ export function ScheduleWorkspacePanel({
);
};
+ // Point the pool booking at the chosen same-day train, then put it on wagons.
+ // If the wagon step fails (that train is short too) the booking stays paid &
+ // unassigned in the pool — nothing is lost, staff just pick another train.
+ const doPoolAssign = () => {
+ if (!poolAssign || !poolTarget) return;
+ const { id: bookingId, reference } = poolAssign;
+ moveSchedule
+ .mutateAsync({ bookingId, trainScheduleId: poolTarget })
+ .then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
+ .then(() => {
+ toast({
+ title: `${reference} assigned`,
+ description: "Booking placed on the selected train with wagons pinned.",
+ });
+ setPoolAssign(null);
+ onChanged();
+ void poolQuery.refetch();
+ })
+ .catch((error) =>
+ toast({
+ title: `Could not assign ${reference}`,
+ description: apiErrorMessage(
+ error,
+ "The selected train has no free wagon of the required type.",
+ ),
+ variant: "destructive",
+ }),
+ );
+ };
+
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -465,20 +520,41 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
+ waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={
canManage ? (
-
- }
- loading={assign.isPending}
- onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
+
+
+ }
+ loading={assign.isPending}
+ onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
+ >
+ Add
+
+
+
- Add
-
-
+ }
+ onClick={() => {
+ setPoolAssign({ id: b.id, reference: b.reference });
+ setPoolTarget(null);
+ }}
+ >
+ Add to…
+
+
+
) : null
}
/>
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
+ {/* Pool → same-day train assignment modal */}
+ setPoolAssign(null)}
+ title={
+
+
+
+ Assign {poolAssign?.reference ?? "booking"} to a train on this day
+
+
+ }
+ centered
+ radius="lg"
+ >
+
+
+ All open trains departing on this schedule's day. Pick one with
+ free wagons — the booking is placed and its wagons pinned in one step.
+
+
+
+ setPoolAssign(null)}>
+ Cancel
+
+ }
+ onClick={doPoolAssign}
+ >
+ Assign to train
+
+
+
+
+
{/* Reassign modal */}
{status ? : null}
+ {waitingForWagon ? (
+
+
+ Waiting for wagon
+
+
+ ) : null}
{loadingStatus ? (
- {trainStatusLabel(composition.status)}
-
+
+
+ {trainStatusLabel(composition.status)}
+
+
+ IMP {composition.importTrainNumber ?? "—"}
+
+
+ EXP {composition.exportTrainNumber ?? "—"}
+
+
}
action={
@@ -289,6 +301,16 @@ export default function TrainBuilderDetailPage() {
{schedule.reference ?? schedule.id.slice(0, 8)}
+ {schedule.trainNumber ? (
+
+ {schedule.trainNumber}
+
+ ) : null}
+ {schedule.direction ? (
+
+ {schedule.direction}
+
+ ) : null}
{schedule.status}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
index 53bd1a818..de6b96aa5 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
@@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
-import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
+import {
+ directionColor,
+ directionRowStyle,
+ trainStatusColor,
+ trainStatusLabel,
+} from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import type {
BuiltTrainListFilters,
@@ -146,6 +151,32 @@ export default function TrainBuilderListPage() {
),
},
+ {
+ id: "numbers",
+ header: "Train No.",
+ meta: { headerClassName, cellClassName },
+ cell: ({ row }) => {
+ const active = row.original.activeSchedule;
+ return (
+
+ {active?.trainNumber ? (
+
+
+ {active.trainNumber}
+
+
+ {active.direction ?? "—"}
+
+
+ ) : null}
+
+ IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
+ {row.original.exportTrainNumber ?? "—"}
+
+
+ );
+ },
+ },
{
id: "yard",
header: "Yard",
@@ -293,6 +324,7 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
+ rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {
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 2fa5b2ee0..671b351b7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -244,6 +244,21 @@ export default function TrainScheduleV2DetailPage() {
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
+ // EXPORT schedules render the consist back-to-front (the train turns around
+ // for the return run) — DISPLAY ONLY: stored sequenceNos, allocations,
+ // documents, and the adjust-consist / placement flows keep the as-built order.
+ const isExportDisplay = schedule?.direction === "EXPORT";
+ const displayWagonPlanOriented = useMemo(
+ () => (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 }) => {
if (!schedule || !scheduleId) return null;
@@ -683,7 +698,12 @@ export default function TrainScheduleV2DetailPage() {
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
-
+ {isExportDisplay && displayWagonPlanOriented.length ? (
+
+ Shown rear-first (export direction) — positions keep their original numbers.
+
+ ) : null}
+
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
{!hasContainerStep ? (
@@ -760,15 +780,16 @@ export default function TrainScheduleV2DetailPage() {
+ {isExportDisplay && diagramWagons.length ? (
+
+ Shown rear-first (export direction) — positions keep their original numbers.
+
+ ) : null}
) : null}
+ {schedule.train ? (
+
+ Train {schedule.train.code}
+
+ ) : null}
{
- // Schedules created from the Train Builder carry the train code;
- // legacy rows fall back to their locomotive set.
+ // Schedules created from the Train Builder show the direction-matched
+ // run number first (falling back to the train code); legacy rows fall
+ // back to their locomotive set.
if (row.original.train) {
+ const subtitle = [row.original.trainNumber ? row.original.train.code : null,
+ row.original.train.trainName]
+ .filter(Boolean)
+ .join(" · ");
return (
- {row.original.train.code}
+ {row.original.trainNumber ?? row.original.train.code}
- {row.original.train.trainName ? (
+ {subtitle ? (
- {row.original.train.trainName}
+ {subtitle}
) : null}
@@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() {
label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"}
- data={(trainsQuery.data ?? []).map((train) => ({
- value: train.id,
- label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""} · ${
- train.locomotives.length
- } locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
- train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
- }`,
- }))}
+ data={(trainsQuery.data ?? []).map((train) => {
+ // Route direction picks which of the train's typed pair this run uses.
+ const runNumber =
+ selectedRoute?.direction === "IMPORT"
+ ? train.importTrainNumber
+ : train.exportTrainNumber;
+ return {
+ value: train.id,
+ label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""}${
+ runNumber ? ` · runs as ${runNumber}` : ""
+ } · ${train.locomotives.length} locos · ${train.wagonCount} wagons${
+ train.atOriginYard ? "" : " · not at origin yard"
+ }${train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""}`,
+ };
+ })}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
searchable
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx
index 0186d5acb..fb02133f2 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx
@@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
- "reopenDelayMinutes",
];
const payload: Partial> = {};
for (const key of fields) {
@@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
-
- setForm((current) => ({ ...current, reopenDelayMinutes: value }))
- }
- min={1}
- disabled={loading}
- />
void handleSave()}>
Save rules
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index d88cd3ed6..e6af4c7dc 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -17,11 +17,27 @@ export interface YardRefLite {
label: string;
}
+export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
+
+/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
+export interface ActiveScheduleRef {
+ id: string;
+ status: string;
+ reference: string | null;
+ direction: TradeDirection | null;
+ trainNumber: string | null;
+}
+
export interface BuiltTrainSummary {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
+ /** Fixed IMPORT (even) run number typed at build time. */
+ importTrainNumber: string | null;
+ /** Fixed EXPORT (odd) run number typed at build time. */
+ exportTrainNumber: string | null;
+ activeSchedule: ActiveScheduleRef | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -80,13 +96,15 @@ export interface TrainComposition {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
+ importTrainNumber: string | null;
+ exportTrainNumber: string | null;
notes: string | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
totals: TrainCompositionTotals;
- activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
+ activeSchedules: ActiveScheduleRef[];
editable: boolean;
}
@@ -112,6 +130,10 @@ export interface BuiltTrainListResponse {
export interface BuildTrainPayload {
code: string;
+ /** EXPORT run number — odd, unique across trains (e.g. 8001). */
+ exportTrainNumber: string;
+ /** IMPORT run number — even, unique across trains (e.g. 8002). */
+ importTrainNumber: string;
currentYardId: string;
locomotiveIds: string[];
wagonIds?: string[];
@@ -125,6 +147,8 @@ export interface AvailableTrain {
code: string;
trainName: string | null;
status: BuiltTrainStatus;
+ importTrainNumber: string | null;
+ exportTrainNumber: string | null;
currentYardId: string | null;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index ee301b970..a80c5a71d 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -7,7 +7,8 @@ export type SchedulingStatus =
| "HOLDING"
| "ELIGIBLE"
| "SCHEDULED"
- | "DISPATCHED";
+ | "DISPATCHED"
+ | "WAITING_FOR_WAGON";
export type TrainScheduleStatus =
| "DRAFT"
@@ -94,10 +95,20 @@ export interface FleetAvailabilityRow {
shortfall: number;
}
+/** Per-booking wagon shortage: how many wagons of which type the booking still lacks. */
+export interface BookingWagonShortage {
+ wagonTypeCodes: string;
+ wagonsNeeded: number;
+ wagonsAvailable: number;
+ wagonsShort: number;
+}
+
export interface DeferredBookingRow {
id: string;
reference: string;
reason: string;
+ /** Set when the deferral is a fleet-stock shortage (absent for config issues). */
+ shortage?: BookingWagonShortage | null;
}
export interface TrainSchedulingGlobalRules {
@@ -114,7 +125,6 @@ export interface TrainSchedulingGlobalRules {
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
- reopenDelayMinutes: number;
}
export interface TrainSchedulePreviewResponse {
@@ -493,7 +503,6 @@ export interface ScheduleWindowRule {
windowOpenHour: number | null;
windowCloseHour: number | null;
windowDurationHours: number | null;
- reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
@@ -831,6 +840,7 @@ export interface CompositionUnassignedBooking {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
+ shortage?: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
diff --git a/apps/edr-freight-web/backoffice/src/utils/bookingWindowDisplay.test.ts b/apps/edr-freight-web/backoffice/src/utils/bookingWindowDisplay.test.ts
new file mode 100644
index 000000000..3f7c510ae
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/utils/bookingWindowDisplay.test.ts
@@ -0,0 +1,264 @@
+import { describe, expect, it } from "vitest";
+
+import { bookingWindowUiState } from "@edr/ui-common";
+import type {
+ BookingWindowStateInput,
+ BookingWindowUiState,
+} from "@edr/ui-common";
+
+/**
+ * Scenario table for the shared badge/countdown state. This is the logic that
+ * previously let a full export train show an "Upcoming" badge above a live
+ * "Window closes in …" countdown — every row asserts badge kind, countdown
+ * target, and bookability TOGETHER, so they can never disagree again.
+ */
+
+const OPENS = "2026-07-26T05:00:00.000Z";
+const CLOSES = "2026-07-27T05:00:00.000Z";
+const DOC_ENDS = "2026-07-24T08:30:00.000Z";
+const PAY_ENDS = "2026-07-24T09:30:00.000Z";
+
+/** A full row with every timestamp present; scenarios override what they test. */
+function row(over: Partial): BookingWindowStateInput {
+ return {
+ windowPhase: "OPEN",
+ bookingWindowStatus: "OPEN",
+ windowOpensAt: OPENS,
+ windowClosesAt: CLOSES,
+ docReviewEndsAt: DOC_ENDS,
+ paymentPhaseEndsAt: PAY_ENDS,
+ ...over,
+ };
+}
+
+interface Scenario {
+ name: string;
+ input: BookingWindowStateInput;
+ expected: BookingWindowUiState;
+}
+
+const scenarios: Scenario[] = [
+ // ---- export FCFS lifecycle -------------------------------------------------
+ {
+ name: "export announced, before lead window (PRE_WINDOW/CLOSED)",
+ input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
+ },
+ {
+ name: "export window open, space left (OPEN/OPEN)",
+ input: row({}),
+ expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
+ },
+ {
+ name: "export filled mid-window (OPEN/FULL) — the reported bug",
+ input: row({ bookingWindowStatus: "FULL" }),
+ expected: { kind: "FULL", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "export space freed after an expiry cleared FULL (OPEN/OPEN again)",
+ input: row({}),
+ expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
+ },
+ {
+ name: "export window over (DONE/CLOSED)",
+ input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "export departed while full (DONE/FULL)",
+ input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+
+ // ---- import daily cycle ----------------------------------------------------
+ {
+ name: "import before booking day (PRE_WINDOW/CLOSED)",
+ input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
+ },
+ {
+ name: "import window open (OPEN/OPEN)",
+ input: row({}),
+ expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
+ },
+ {
+ name: "import window closed, staff reviewing docs (DOC_REVIEW/CLOSED)",
+ input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
+ },
+ {
+ name: "import payment phase, selected customers paying (PAYMENT/CLOSED)",
+ input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
+ },
+ {
+ name: "import batch tentatively filled the train (PAYMENT/FULL) — phase wins, unpaid may still free space",
+ input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "FULL" }),
+ expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
+ },
+ {
+ name: "import doc review while flag already FULL (DOC_REVIEW/FULL) — phase wins",
+ input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "FULL" }),
+ expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
+ },
+ {
+ name: "import reopen cycle scheduled (PRE_WINDOW/CLOSED, cycle 2)",
+ input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
+ },
+ {
+ name: "import reopen refused while train still FULL (PRE_WINDOW/FULL)",
+ input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "FULL" }),
+ expected: { kind: "FULL", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "import train full and finalized (DONE/FULL)",
+ input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "import no cycle fits before departure (DONE/CLOSED)",
+ input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "legacy closed-for-the-day row (CLOSED_FOR_DAY/CLOSED)",
+ input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "legacy closed-for-the-day row while full (CLOSED_FOR_DAY/FULL)",
+ input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "FULL" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+
+ // ---- desync / stale rows ---------------------------------------------------
+ {
+ name: "phase OPEN but desk flag CLOSED (desync) — closed, no countdown",
+ input: row({ bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "dispatched train stuck at OPEN/CLOSED (tick skips non-scheduled rows)",
+ input: row({ bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "FULL flag with no phase at all (legacy pre-window-engine row)",
+ input: row({ windowPhase: null, bookingWindowStatus: "FULL" }),
+ expected: { kind: "FULL", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "legacy row, no phase, desk open (null/OPEN) — not phase-driven, shows closed",
+ input: row({ windowPhase: null }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "unknown future phase value — safe fallback to closed",
+ input: row({ windowPhase: "SOMETHING_NEW" }),
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+
+ // ---- missing timestamps (no countdown, badge still right) -------------------
+ {
+ name: "PRE_WINDOW without an opens-at timestamp",
+ input: row({
+ windowPhase: "PRE_WINDOW",
+ bookingWindowStatus: "CLOSED",
+ windowOpensAt: null,
+ }),
+ expected: { kind: "PRE_WINDOW", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "OPEN without a closes-at timestamp",
+ input: row({ windowClosesAt: null }),
+ expected: { kind: "OPEN", countdownTo: null, isBookable: true },
+ },
+ {
+ name: "DOC_REVIEW without an ends-at timestamp",
+ input: row({
+ windowPhase: "DOC_REVIEW",
+ bookingWindowStatus: "CLOSED",
+ docReviewEndsAt: null,
+ }),
+ expected: { kind: "DOC_REVIEW", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "PAYMENT without an ends-at timestamp",
+ input: row({
+ windowPhase: "PAYMENT",
+ bookingWindowStatus: "CLOSED",
+ paymentPhaseEndsAt: null,
+ }),
+ expected: { kind: "PAYMENT", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "row with every field null",
+ input: {
+ windowPhase: null,
+ bookingWindowStatus: null,
+ windowOpensAt: null,
+ windowClosesAt: null,
+ docReviewEndsAt: null,
+ paymentPhaseEndsAt: null,
+ },
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+ {
+ name: "row with every field undefined (structural minimum)",
+ input: {},
+ expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
+ },
+
+ // ---- countdown targets track the right deadline per phase -------------------
+ {
+ name: "PRE_WINDOW counts to opens-at, not closes-at",
+ input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
+ },
+ {
+ name: "OPEN counts to closes-at, not doc review",
+ input: row({}),
+ expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
+ },
+ {
+ name: "DOC_REVIEW counts to review end, not payment end",
+ input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
+ },
+ {
+ name: "PAYMENT counts to payment end, not window close",
+ input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
+ expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
+ },
+];
+
+describe("bookingWindowUiState", () => {
+ it.each(scenarios)("$name", ({ input, expected }) => {
+ expect(bookingWindowUiState(input)).toEqual(expected);
+ });
+
+ it("never yields a countdown on a non-bookable FULL state, whatever else is set", () => {
+ for (const phase of ["OPEN", "PRE_WINDOW", null, "ANYTHING"]) {
+ const state = bookingWindowUiState(row({ windowPhase: phase, bookingWindowStatus: "FULL" }));
+ expect(state.kind).toBe("FULL");
+ expect(state.countdownTo).toBeNull();
+ expect(state.isBookable).toBe(false);
+ }
+ });
+
+ it("is bookable ONLY when phase and desk flag are both OPEN", () => {
+ const combos: Array<[string | null, string | null]> = [];
+ for (const phase of ["PRE_WINDOW", "OPEN", "DOC_REVIEW", "PAYMENT", "DONE", "CLOSED_FOR_DAY", null]) {
+ for (const status of ["OPEN", "CLOSED", "FULL", null]) {
+ combos.push([phase, status]);
+ }
+ }
+ for (const [phase, status] of combos) {
+ const state = bookingWindowUiState(
+ row({ windowPhase: phase, bookingWindowStatus: status }),
+ );
+ expect(state.isBookable).toBe(phase === "OPEN" && status === "OPEN");
+ }
+ });
+});
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
index f9a3dd246..50e142708 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
@@ -6,7 +6,7 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
-import { CountdownTimer } from "@edr/ui-common";
+import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
@@ -50,54 +50,34 @@ function windowLabel(w: MyBookingWindow): string {
}
/**
- * The countdown for whichever phase the window is currently in. Phases run:
- * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) →
- * document review (docReviewEndsAt) → payment (paymentPhaseEndsAt).
+ * The countdown for the window's UI state (shared with the status badge via
+ * `bookingWindowUiState`, so the two can never contradict — a FULL train shows
+ * no ticking "closes in" under a non-open badge).
*
* `label` describes the deadline being counted down to; `expiredText` names the
* NEXT step so that when a deadline lapses between the 60s refetches the row
* announces what comes next ("Booking opening now…", "Review starting…") rather
* than the bare word "Expired". Returns null when no phase is timing down.
*/
+const COUNTDOWN_TEXT: Partial<
+ Record<
+ ReturnType["kind"],
+ { label: string; expiredText: string }
+ >
+> = {
+ PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
+ OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
+ DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
+ PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
+};
+
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
- switch (w.windowPhase) {
- case "PRE_WINDOW":
- if (w.windowOpensAt)
- return {
- label: "Booking opens in",
- deadline: w.windowOpensAt,
- expiredText: "Booking opening now…",
- };
- return null;
- case "OPEN":
- if (w.windowClosesAt)
- return {
- label: "Window closes in",
- deadline: w.windowClosesAt,
- expiredText: "Document review starting…",
- };
- return null;
- case "DOC_REVIEW":
- if (w.docReviewEndsAt)
- return {
- label: "Document review ends in",
- deadline: w.docReviewEndsAt,
- expiredText: "Payment starting…",
- };
- return null;
- case "PAYMENT":
- if (w.paymentPhaseEndsAt)
- return {
- label: "Payment due in",
- deadline: w.paymentPhaseEndsAt,
- expiredText: "Payment window closing…",
- };
- return null;
- default:
- return null;
- }
+ const state = bookingWindowUiState(w);
+ const text = COUNTDOWN_TEXT[state.kind];
+ if (!state.countdownTo || !text) return null;
+ return { ...text, deadline: state.countdownTo };
}
function Pill({
@@ -147,19 +127,47 @@ function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"]
}
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
- if (w.isOpenNow) {
- return (
-
- Open now
-
- );
- }
- if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
- return (
-
- Opens at {fmtTime(w.windowOpensAt)} EAT
-
- );
+ const state = bookingWindowUiState(w);
+ switch (state.kind) {
+ case "OPEN":
+ return (
+
+ Open now
+
+ );
+ case "FULL":
+ return (
+
+ Train full
+
+ );
+ case "PRE_WINDOW":
+ if (w.windowOpensAt) {
+ return (
+
+ Opens at {fmtTime(w.windowOpensAt)} EAT
+
+ );
+ }
+ break;
+ case "DOC_REVIEW":
+ return (
+
+ Document review
+
+ );
+ case "PAYMENT":
+ return (
+
+ Payment window
+
+ );
+ case "CLOSED":
+ return (
+
+ Closed
+
+ );
}
return (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
index 3b7ef132b..a8d9d0830 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx
@@ -18,7 +18,8 @@ import {
ChevronRight,
Clock,
} from "lucide-react";
-import { CountdownTimer } from "@edr/ui-common";
+import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
+import type { BookingWindowUiKind } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import {
@@ -90,50 +91,29 @@ function windowLabel(w: MyBookingWindow): string {
}
/**
- * The countdown for whichever phase the window is currently in, mirroring the
- * home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
- * deadline that lapses between refetches announces what comes next rather than
- * the bare "Expired".
+ * The countdown for the window's UI state, mirroring the home dashboard's
+ * Booking Windows card. Derived from the SAME state as the badge
+ * (`bookingWindowUiState`) so they can never contradict — a full train shows
+ * no ticking countdown. `expiredText` names the NEXT step so a deadline that
+ * lapses between refetches announces what comes next rather than the bare
+ * "Expired".
*/
+const COUNTDOWN_TEXT: Partial<
+ Record
+> = {
+ PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
+ OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
+ DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
+ PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
+};
+
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
- switch (w.windowPhase) {
- case "PRE_WINDOW":
- return w.windowOpensAt
- ? {
- label: "Booking opens in",
- deadline: w.windowOpensAt,
- expiredText: "Booking opening now…",
- }
- : null;
- case "OPEN":
- return w.windowClosesAt
- ? {
- label: "Window closes in",
- deadline: w.windowClosesAt,
- expiredText: "Document review starting…",
- }
- : null;
- case "DOC_REVIEW":
- return w.docReviewEndsAt
- ? {
- label: "Document review ends in",
- deadline: w.docReviewEndsAt,
- expiredText: "Payment starting…",
- }
- : null;
- case "PAYMENT":
- return w.paymentPhaseEndsAt
- ? {
- label: "Payment due in",
- deadline: w.paymentPhaseEndsAt,
- expiredText: "Payment window closing…",
- }
- : null;
- default:
- return null;
- }
+ const state = bookingWindowUiState(w);
+ const text = COUNTDOWN_TEXT[state.kind];
+ if (!state.countdownTo || !text) return null;
+ return { ...text, deadline: state.countdownTo };
}
/**
@@ -148,9 +128,21 @@ function isPast(w: MyBookingWindow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
+/** Badge label + Mantine color per UI state — same state the countdown uses. */
+const KIND_BADGE: Record = {
+ OPEN: { label: "Open now", color: "edr-green" },
+ FULL: { label: "Train full", color: "red" },
+ PRE_WINDOW: { label: "Opens soon", color: "yellow" },
+ DOC_REVIEW: { label: "Document review", color: "gray" },
+ PAYMENT: { label: "Payment due", color: "gray" },
+ CLOSED: { label: "Closed", color: "gray" },
+};
+
function WindowCard({ w }: { w: MyBookingWindow }) {
const cd = phaseCountdown(w);
- const open = w.isOpenNow;
+ const state = bookingWindowUiState(w);
+ const badge = KIND_BADGE[state.kind];
+ const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -183,13 +175,11 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
)}
- {open
- ? "Open now"
- : windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
+ {badge.label}
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 39e8985ee..bd6c8ffeb 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -182,6 +182,8 @@ export enum SchedulingStatus {
Eligible = "ELIGIBLE",
Scheduled = "SCHEDULED",
Dispatched = "DISPATCHED",
+ /** Paid, but no wagon of the required type was free — held in the day pool for manual placement. */
+ WaitingForWagon = "WAITING_FOR_WAGON",
}
export enum TrainScheduleStatus {
diff --git a/packages/ui-common/src/components/data-table/table.tsx b/packages/ui-common/src/components/data-table/table.tsx
index 5dbe7277b..957da3300 100644
--- a/packages/ui-common/src/components/data-table/table.tsx
+++ b/packages/ui-common/src/components/data-table/table.tsx
@@ -15,6 +15,8 @@ export function DataTable({
data,
status,
onRowClick,
+ rowStyle,
+ rowClassName,
tableOptions,
pagination,
footer,
@@ -115,11 +117,15 @@ export function DataTable({
onRowClick(row.original);
}}
role={onRowClick ? "button" : ""}
- className={
+ style={rowStyle?.(row.original)}
+ className={[
onRowClick
- ? "cursor-pointer hover:bg-accent hover:text-foreground "
- : ""
- }
+ ? "cursor-pointer hover:bg-accent hover:text-foreground"
+ : "",
+ rowClassName?.(row.original) ?? "",
+ ]
+ .join(" ")
+ .trim()}
>
{row.getVisibleCells().map((cell) => (
{
data: TData[];
status?: "loading" | "error" | "success";
onRowClick?: (row: TData) => void;
+ /** Per-row inline style (e.g. data-driven background tints via CSS variables). */
+ rowStyle?: (row: TData) => React.CSSProperties | undefined;
+ /** Per-row extra class, appended after the built-in clickable-row classes. */
+ rowClassName?: (row: TData) => string | undefined;
tableOptions?: Omit<
TableOptions,
"data" | "columns" | "getCoreRowModel"
diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts
index f7e609412..8fb36be26 100644
--- a/packages/ui-common/src/index.ts
+++ b/packages/ui-common/src/index.ts
@@ -69,3 +69,10 @@ export * from "./components/select";
export * from "./components/switch";
export * from "./components/separator";
export * from "./components/field";
+
+export { bookingWindowUiState } from "./lib/booking-window-display";
+export type {
+ BookingWindowUiKind,
+ BookingWindowStateInput,
+ BookingWindowUiState,
+} from "./lib/booking-window-display";
diff --git a/packages/ui-common/src/lib/booking-window-display.ts b/packages/ui-common/src/lib/booking-window-display.ts
new file mode 100644
index 000000000..242db3a3e
--- /dev/null
+++ b/packages/ui-common/src/lib/booking-window-display.ts
@@ -0,0 +1,108 @@
+/**
+ * Single source of truth for how a booking window row is presented to a
+ * customer or staff list: which status badge to show and which deadline (if
+ * any) to count down to.
+ *
+ * The badge and the countdown MUST be derived together. They used to be
+ * computed independently (badge from `isOpenNow`, countdown from
+ * `windowPhase`), which let them contradict each other — an export train that
+ * filled mid-window kept `windowPhase='OPEN'` (space can free again if a pay
+ * window lapses) while `bookingWindowStatus='FULL'`, so the card showed an
+ * "Upcoming" badge above a live "Window closes in …" countdown.
+ *
+ * Phase/status matrix this resolves (server fields on the schedule row):
+ * - windowPhase: PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → DONE
+ * (export skips the review/payment phases; CLOSED_FOR_DAY is legacy)
+ * - bookingWindowStatus: OPEN | CLOSED | FULL — whether the booking desk
+ * actually accepts bookings right now.
+ */
+
+export type BookingWindowUiKind =
+ /** Bookable right now (phase OPEN and the desk flag agrees). */
+ | "OPEN"
+ /** Train has no capacity left — not bookable; may reopen if a reservation expires. */
+ | "FULL"
+ /** Announced, opens at `countdownTo`. */
+ | "PRE_WINDOW"
+ /** Window closed, staff reviewing documents (import cycle). */
+ | "DOC_REVIEW"
+ /** Batch ran, selected customers are paying (import cycle). */
+ | "PAYMENT"
+ /** Terminal or not bookable for any other reason. */
+ | "CLOSED";
+
+export interface BookingWindowStateInput {
+ windowPhase?: string | null;
+ bookingWindowStatus?: string | null;
+ windowOpensAt?: string | null;
+ windowClosesAt?: string | null;
+ docReviewEndsAt?: string | null;
+ paymentPhaseEndsAt?: string | null;
+}
+
+export interface BookingWindowUiState {
+ kind: BookingWindowUiKind;
+ /** ISO deadline a countdown may tick toward; null = show no countdown. */
+ countdownTo: string | null;
+ /** True only when the customer can book right now. */
+ isBookable: boolean;
+}
+
+export function bookingWindowUiState(
+ w: BookingWindowStateInput,
+): BookingWindowUiState {
+ const phase = w.windowPhase ?? null;
+ const status = w.bookingWindowStatus ?? null;
+
+ if (phase === "DONE" || phase === "CLOSED_FOR_DAY") {
+ return { kind: "CLOSED", countdownTo: null, isBookable: false };
+ }
+
+ // Mid-cycle phases win over the FULL flag: the batch may have tentatively
+ // filled the train, but an unpaid reservation can still expire and free
+ // space, so "document review" / "payment" is the truthful state here.
+ if (phase === "DOC_REVIEW") {
+ return {
+ kind: "DOC_REVIEW",
+ countdownTo: w.docReviewEndsAt ?? null,
+ isBookable: false,
+ };
+ }
+ if (phase === "PAYMENT") {
+ return {
+ kind: "PAYMENT",
+ countdownTo: w.paymentPhaseEndsAt ?? null,
+ isBookable: false,
+ };
+ }
+
+ // Outside the resolving phases a FULL train is simply not bookable — no
+ // countdown either: ticking toward "closes in" would promise a window the
+ // customer cannot use.
+ if (status === "FULL") {
+ return { kind: "FULL", countdownTo: null, isBookable: false };
+ }
+
+ if (phase === "PRE_WINDOW") {
+ return {
+ kind: "PRE_WINDOW",
+ countdownTo: w.windowOpensAt ?? null,
+ isBookable: false,
+ };
+ }
+
+ if (phase === "OPEN") {
+ if (status === "OPEN") {
+ return {
+ kind: "OPEN",
+ countdownTo: w.windowClosesAt ?? null,
+ isBookable: true,
+ };
+ }
+ // Phase says OPEN but the desk flag disagrees (CLOSED): not bookable, and
+ // no countdown that pretends otherwise.
+ return { kind: "CLOSED", countdownTo: null, isBookable: false };
+ }
+
+ return { kind: "CLOSED", countdownTo: null, isBookable: false };
+}
From 30a91691c8f7374f944237cb6cd34959b9de0fcb Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 12:50:19 +0300
Subject: [PATCH 35/67] Update seat availability
---
.../common/utils/journey-direction.utils.ts | 37 ++++
.../src/modules/seats/seats.service.ts | 162 ++++------------
.../src/modules/segments/segments.service.ts | 176 ++++++++----------
3 files changed, 147 insertions(+), 228 deletions(-)
create mode 100644 apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts
diff --git a/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts
new file mode 100644
index 000000000..9901eed36
--- /dev/null
+++ b/apps/edr-passenger-api/src/common/utils/journey-direction.utils.ts
@@ -0,0 +1,37 @@
+import { JourneyDirection } from '../../modules/seats/seats.dto';
+
+/**
+ * Shared by SeatsService (seatmap display, hold-creation conflict checks) and
+ * SegmentsService (search results' availability counts, EnhancedSeatsService) — the
+ * single source of truth for whether two journey directions on the same schedule
+ * should be treated as conflicting. Without this, a round-trip's OUTBOUND and RETURN
+ * legs on the same schedule would wrongly block each other's seats.
+ *
+ * Check if two journey directions conflict (should not be allowed simultaneously).
+ * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on the same schedule.
+ */
+export function checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
+ // OUTBOUND and RETURN are allowed simultaneously (round-trip on the same schedule)
+ if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
+ (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
+ return false;
+ }
+
+ // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
+ if (current === existing) {
+ return true;
+ }
+
+ // ONE_WAY conflicts with other ONE_WAY bookings only
+ if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
+ return true;
+ }
+
+ // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
+ if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
+ return true;
+ }
+
+ // Default: no conflict
+ return false;
+}
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index b2bff14b6..708f57792 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -6,6 +6,7 @@ import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
+import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
@Injectable()
export class SeatsService {
@@ -137,10 +138,10 @@ export class SeatsService {
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
const n = coachTypeName.toLowerCase();
- // Explicit VIP name check first
- if (n.includes('vip')) return 'VIP_BED';
- // Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
- if (bedsPerRoom === 4) return 'VIP_BED';
+ // Name-based: VIP / Soft Berth Coach → VIP_BED
+ if (n.includes('vip') || n.includes('soft')) return 'VIP_BED';
+ // Beds-per-room fallback: 2 or 4 beds per room = VIP, more = Economy
+ if (bedsPerRoom != null && bedsPerRoom <= 4) return 'VIP_BED';
return 'ECONOMY_BED';
}
@@ -187,6 +188,11 @@ export class SeatsService {
return legacyMap[col?.toUpperCase()] ?? null;
}
+ // Delegates the actual "is this seat held/booked for this leg" determination to
+ // SegmentsService.getSeatAvailabilityMap — the same canonical check search results
+ // (availabilityByClass) use — so the seatmap and search results can never disagree
+ // about seat availability again. Previously this method carried its own
+ // separately-written copy of the same hold/JourneySegment-overlap logic.
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
@@ -197,138 +203,42 @@ export class SeatsService {
const statusMap = new Map();
if (seatIds.length === 0) return statusMap;
- // Resolve the requested leg's sequence range once
- let reqFrom: number | undefined;
- let reqTo: number | undefined;
- let allStopTimes: { stationId: string; sequence: number }[] | null = null;
-
- const getStopTimes = async () => {
- if (!allStopTimes) {
- allStopTimes = await this.prisma.tripStopTime.findMany({
- where: { scheduleId },
- select: { stationId: true, sequence: true },
- });
- }
- return allStopTimes;
- };
+ const stopTimes = await this.prisma.tripStopTime.findMany({
+ where: { scheduleId },
+ select: { stationId: true, sequence: true },
+ });
+ // No specific leg requested (or it doesn't resolve to real stops on this
+ // schedule) — conservatively treat the whole schedule as one big leg, so any
+ // resolvable hold/booking anywhere on it blocks these seats. Matches this
+ // method's previous behavior when called without origin/destination.
+ let reqFrom = -Infinity;
+ let reqTo = Infinity;
if (originStationId && destinationStationId) {
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- reqFrom = seqOf(originStationId);
- reqTo = seqOf(destinationStationId);
- }
-
- // ── Active holds ──────────────────────────────────────────────────────────
- const activeHolds = await this.prisma.seatHold.findMany({
- where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
- select: { seatIds: true, createdBy: true },
- });
-
- const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
-
- for (const hold of activeHolds) {
- let holdFrom: number | undefined;
- let holdTo: number | undefined;
- let holdDirection = JourneyDirection.ONE_WAY;
-
- try {
- if (hold.createdBy?.trimStart().startsWith('{')) {
- const meta = JSON.parse(hold.createdBy);
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- holdFrom = seqOf(meta.originStationId);
- holdTo = seqOf(meta.destinationStationId);
- holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
- }
- } catch { /* ignore */ }
-
- for (const seatId of hold.seatIds) {
- if (!seatIds.includes(seatId)) continue;
-
- // Check leg overlap
- const legsOverlap =
- reqFrom === undefined || reqTo === undefined ||
- holdFrom === undefined || holdTo === undefined ||
- (holdFrom < reqTo && reqFrom < holdTo);
-
- // Check direction conflict
- const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
-
- if (!legsOverlap || !directionsConflict) {
- // This hold does not conflict with the requested leg/direction.
- // Explicitly mark AVAILABLE so the DB's HELD status (set by the
- // opposing-direction hold) does not bleed through via the fallback.
- if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
- continue;
- }
-
- statusMap.set(seatId, 'HELD');
+ const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
+ const resolvedFrom = seqOf(originStationId);
+ const resolvedTo = seqOf(destinationStationId);
+ if (resolvedFrom !== undefined && resolvedTo !== undefined) {
+ reqFrom = resolvedFrom;
+ reqTo = resolvedTo;
}
}
- // ── Confirmed bookings via JourneySegment ─────────────────────────────────
- const bookedSegments = await this.prisma.journeySegment.findMany({
- where: {
- scheduleId,
- seatId: { in: seatIds },
- journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
- },
- select: { seatId: true, departureStationId: true, arrivalStationId: true },
- });
+ const availability = await this.segmentsService.getSeatAvailabilityMap(
+ scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
+ );
- if (reqFrom !== undefined && reqTo !== undefined) {
- const stops = await getStopTimes();
- const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
- for (const seg of bookedSegments) {
- if (!seg.seatId) continue;
- const segFrom = seqOf(seg.departureStationId);
- const segTo = seqOf(seg.arrivalStationId);
- if (segFrom !== undefined && segTo !== undefined) {
- if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
- } else {
- statusMap.set(seg.seatId, 'BOOKED');
- }
- }
- } else {
- for (const seg of bookedSegments) {
- if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
- }
+ // Every requested seat defaults to AVAILABLE — this also guards against a stale
+ // persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding
+ // through getSeatMap's own fallback, since that fallback only triggers when this
+ // map has no entry at all for a given seat.
+ for (const seatId of seatIds) {
+ statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
}
return statusMap;
}
- /**
- * Check if two journey directions conflict (should not be allowed simultaneously)
- * For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
- */
- private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
- // OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
- if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
- (current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
- return false;
- }
-
- // Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
- if (current === existing) {
- return true;
- }
-
- // ONE_WAY conflicts with other ONE_WAY bookings only
- if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
- return true;
- }
-
- // ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
- if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
- return true;
- }
-
- // Default: no conflict
- return false;
- }
-
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
@@ -432,7 +342,7 @@ export class SeatsService {
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
- const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
+ const directionsConflict = checkDirectionConflict(currentDirection, holdDirection);
if (!directionsConflict) continue;
for (const { passengerId, seatId } of dto.passengers) {
diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts
index 16b486bbe..aaeb51e03 100644
--- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts
+++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts
@@ -1,5 +1,7 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
+import { JourneyDirection } from '../seats/seats.dto';
+import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
export interface Segment {
fromStationId: string;
@@ -54,7 +56,12 @@ export class SegmentsService {
}
/**
- * Checks whether a seat is free for the requested leg [reqFrom, reqTo).
+ * Canonical per-seat availability check for a leg [reqFrom, reqTo) — the single
+ * source of truth used by search results (availabilityByClass), the interactive
+ * seatmap (SeatsService.resolveEffectiveStatuses), and hold-conflict checking, so
+ * they can never disagree about whether a given seat is free. Previously
+ * SeatsService maintained its own separately-written copy of this same
+ * hold/booking-overlap logic, which could (and did) drift out of sync with this one.
*
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
*
@@ -66,99 +73,30 @@ export class SegmentsService {
* P3: A(1) → D(4) reqFrom=1, reqTo=4
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
*
+ * journeyDirection lets a round-trip's OUTBOUND and RETURN holds coexist on the
+ * same schedule without blocking each other (see checkDirectionConflict) — omit it
+ * for one-way contexts, where it defaults to ONE_WAY (conflicts with anything).
+ *
* Sources checked:
- * 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
+ * 1. Active SeatHolds — leg + direction decoded from createdBy JSON
+ * ({ originStationId, destinationStationId, journeyDirection })
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
+ * (JourneySegment carries no direction — a confirmed booking always blocks,
+ * regardless of the requester's own direction)
+ *
+ * Returns a map from seatId to 'HELD' | 'BOOKED' — seats with no entry are free.
+ * BOOKED takes priority when a seat is somehow reported as both.
*/
- async isSeatFreeForLeg(
- scheduleId: string,
- seatId: string,
- reqFrom: number,
- reqTo: number,
- ): Promise {
- // ── Load stop-time sequences once ────────────────────────────────────────
- const stopTimes = await this.prisma.tripStopTime.findMany({
- where: { scheduleId },
- select: { stationId: true, sequence: true },
- });
- const seqOf = (stationId: string) =>
- stopTimes.find(s => s.stationId === stationId)?.sequence;
-
- // ── 1. Active holds ───────────────────────────────────────────────────────
- const activeHolds = await this.prisma.seatHold.findMany({
- where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
- });
-
- for (const hold of activeHolds) {
- // Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers }
- let holdFrom: number | undefined;
- let holdTo: number | undefined;
- try {
- if (hold.createdBy) {
- const meta = JSON.parse(hold.createdBy);
- holdFrom = seqOf(meta.originStationId);
- holdTo = seqOf(meta.destinationStationId);
- }
- } catch { /* ignore */ }
-
- if (holdFrom !== undefined && holdTo !== undefined) {
- if (holdFrom < reqTo && reqFrom < holdTo) return false;
- } else {
- // Cannot resolve leg — conservative block
- return false;
- }
- }
-
- // ── 2. Active JourneySegments ─────────────────────────────────────────────
- // Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the
- // full range [min(depSeq), max(arrSeq)] per journey for this seat.
- const bookedLegs = await this.prisma.journeySegment.findMany({
- where: {
- scheduleId,
- seatId,
- journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
- },
- });
-
- // Group legs by journeyId → find the full range each journey occupies
- const journeyRanges = new Map();
- for (const leg of bookedLegs) {
- const depSeq = seqOf(leg.departureStationId);
- const arrSeq = seqOf(leg.arrivalStationId);
- if (depSeq === undefined || arrSeq === undefined) continue;
-
- const existing = journeyRanges.get(leg.journeyId);
- if (!existing) {
- journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq });
- } else {
- journeyRanges.set(leg.journeyId, {
- from: Math.min(existing.from, depSeq),
- to: Math.max(existing.to, arrSeq),
- });
- }
- }
-
- for (const { from, to } of journeyRanges.values()) {
- // Strict overlap: existingFrom < reqTo AND reqFrom < existingTo
- if (from < reqTo && reqFrom < to) return false;
- }
-
- return true;
- }
-
- /**
- * Batch availability check for multiple seats on a single schedule.
- * Replaces N×isSeatFreeForLeg calls with 2 queries total.
- * Returns a Set of seat IDs that are free for [reqFrom, reqTo).
- */
- async getFreeSeatIds(
+ async getSeatAvailabilityMap(
scheduleId: string,
seatIds: string[],
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
reqFrom: number,
reqTo: number,
- ): Promise> {
- if (seatIds.length === 0) return new Set();
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise> {
+ const result = new Map();
+ if (seatIds.length === 0) return result;
const seqOf = (stationId: string) =>
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
@@ -181,29 +119,31 @@ export class SegmentsService {
}),
]);
- // Determine which seats are blocked by active holds
- const holdBlockedSeats = new Set();
+ // ── 1. Active holds ────────────────────────────────────────────────────────
for (const hold of allHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
+ let holdDirection = JourneyDirection.ONE_WAY;
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy as string);
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
+ holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
}
} catch { /* ignore */ }
for (const sid of hold.seatIds) {
if (!seatIdSet.has(sid)) continue;
- // Conservative block if leg can't be resolved; otherwise check overlap
- if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
- holdBlockedSeats.add(sid);
- }
+ // Conservative block if leg can't be resolved; otherwise check overlap.
+ const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
+ if (!legsOverlap) continue;
+ if (!checkDirectionConflict(journeyDirection, holdDirection)) continue;
+ result.set(sid, 'HELD');
}
}
- // Build full journey ranges per seat (group multi-leg journeys)
+ // ── 2. Active JourneySegments — per-seat, per-journey leg ranges ──────────
const journeyRangesBySeat = new Map>();
for (const leg of bookedLegs) {
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
@@ -220,20 +160,52 @@ export class SegmentsService {
: { from: depSeq, to: arrSeq });
}
- const freeSeats = new Set();
for (const seatId of seatIds) {
- if (holdBlockedSeats.has(seatId)) continue;
- let blocked = false;
const rangeMap = journeyRangesBySeat.get(seatId);
- if (rangeMap) {
- for (const { from, to } of rangeMap.values()) {
- if (from < reqTo && reqFrom < to) { blocked = true; break; }
- }
+ if (!rangeMap) continue;
+ for (const { from, to } of rangeMap.values()) {
+ if (from < reqTo && reqFrom < to) { result.set(seatId, 'BOOKED'); break; }
}
- if (!blocked) freeSeats.add(seatId);
}
- return freeSeats;
+ return result;
+ }
+
+ /**
+ * Batch availability check for multiple seats on a single schedule.
+ * Thin wrapper around getSeatAvailabilityMap — returns just the free-seat set.
+ */
+ async getFreeSeatIds(
+ scheduleId: string,
+ seatIds: string[],
+ stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
+ reqFrom: number,
+ reqTo: number,
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise> {
+ if (seatIds.length === 0) return new Set();
+ const statusMap = await this.getSeatAvailabilityMap(
+ scheduleId, seatIds, stopTimesForSeqLookup, reqFrom, reqTo, journeyDirection,
+ );
+ return new Set(seatIds.filter(id => !statusMap.has(id)));
+ }
+
+ /**
+ * Single-seat convenience wrapper around getSeatAvailabilityMap.
+ */
+ async isSeatFreeForLeg(
+ scheduleId: string,
+ seatId: string,
+ reqFrom: number,
+ reqTo: number,
+ journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
+ ): Promise {
+ const stopTimes = await this.prisma.tripStopTime.findMany({
+ where: { scheduleId },
+ select: { stationId: true, sequence: true },
+ });
+ const freeSeats = await this.getFreeSeatIds(scheduleId, [seatId], stopTimes, reqFrom, reqTo, journeyDirection);
+ return freeSeats.has(seatId);
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
From c71a0043d6bb303d99f0ced203220068d9ea52bd Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 10:32:09 +0000
Subject: [PATCH 36/67] implement wagon maintenance feature: add functionality
to detach wagons and move them to maintenance status
---
.../src/modules/trains/dto/build-train.dto.ts | 5 --
.../trains/train-builder.controller.ts | 10 ++++
.../modules/trains/train-builder.service.ts | 56 +++++++++++++++++--
.../trainBuilder/AvailableWagonsPanel.tsx | 36 +++++++++++-
.../trainBuilder/BuildTrainModal.tsx | 39 +++++--------
.../trainBuilder/ConsistWagonList.tsx | 43 ++++++++++----
.../trainBuilder/TrainBuilderDetailPage.tsx | 14 ++++-
.../backoffice/src/services/api.ts | 9 +++
.../src/services/trainBuilder.service.ts | 4 +-
9 files changed, 166 insertions(+), 50 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
index b4548edbb..5ba77fb10 100644
--- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
+++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
@@ -10,11 +10,6 @@ import {
} from 'class-validator';
export class BuildTrainDto {
- @ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
- @IsString()
- @MaxLength(32)
- code!: string;
-
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
@IsString()
@MaxLength(20)
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
index 7ed3e4c42..eec68fcfc 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
@@ -85,6 +85,16 @@ export class TrainBuilderController {
return this.trainBuilderService.removeWagon(id, wagonId);
}
+ @Post(':id/wagons/:wagonId/maintenance')
+ @FleetManage()
+ @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
+ sendWagonToMaintenance(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('wagonId', ParseUUIDPipe) wagonId: string,
+ ) {
+ return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
+ }
+
@Post(':id/reorder-wagons')
@FleetManage()
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
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 d385a424c..c875433fe 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
@@ -59,11 +59,7 @@ export class TrainBuilderService {
}
const trainId = await this.dataSource.transaction(async (manager) => {
- const code = dto.code.trim();
- const existing = await manager.getRepository(Train).findOne({ where: { code } });
- if (existing) {
- throw new ConflictException(`Train code ${code} is already in use`);
- }
+ const code = await this.generateTrainCode(manager);
// Friendly 409 before the partial unique indexes (the race-proof backstop):
// the typed pair may not collide with any train's pair or legacy number.
@@ -418,6 +414,33 @@ export class TrainBuilderService {
return this.getComposition(id);
}
+ /**
+ * Detach one wagon AND flag it for maintenance: it leaves the consist and
+ * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
+ * it clears maintenance. The freed sequence gap is closed.
+ */
+ async sendWagonToMaintenance(id: string, wagonId: string) {
+ await this.dataSource.transaction(async (manager) => {
+ const train = await this.getEditableTrain(manager, id);
+ const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
+ if (!wagon || wagon.trainId !== train.id) {
+ throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
+ }
+ if (wagon.currentTrainScheduleId) {
+ throw new ConflictException(
+ `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
+ );
+ }
+ await manager.getRepository(Wagon).update(wagon.id, {
+ trainId: null,
+ sequenceNumber: null,
+ status: WagonStatus.Maintenance,
+ });
+ await this.resequenceWagons(manager, train.id);
+ });
+ return this.getComposition(id);
+ }
+
/** 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) => {
@@ -469,6 +492,29 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals
+ /**
+ * System-assigned train code `TR-NNNNN`. Draws the next number from the
+ * highest existing `TR-` code and probes past any manual collision so the
+ * unique constraint never rejects the build.
+ */
+ private async generateTrainCode(manager: EntityManager): Promise {
+ const [row]: { max_seq: string | null }[] = await manager.query(
+ `SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
+ FROM freight.trains
+ WHERE code ~ '^TR-[0-9]+$'`,
+ );
+ let seq = Number(row?.max_seq ?? 0) + 1;
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ const code = `TR-${String(seq).padStart(5, '0')}`;
+ const exists = await manager
+ .getRepository(Train)
+ .findOne({ where: { code }, withDeleted: true });
+ if (!exists) return code;
+ seq += 1;
+ }
+ throw new ConflictException('Could not allocate a unique train code');
+ }
+
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
index a75449a9d..c121acd36 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
const typeOptions = useMemo(() => {
const byId = new Map();
for (const wagon of wagonsQuery.data ?? []) {
- if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
+ if (wagon.wagonType) {
+ // e.g. "Flat wagon (NW5)" — name with its type code.
+ byId.set(
+ wagon.wagonType.id,
+ wagon.wagonType.code
+ ? `${wagon.wagonType.name} (${wagon.wagonType.code})`
+ : wagon.wagonType.name,
+ );
+ }
}
return [
{ value: "ALL", label: "All types" },
@@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({
);
};
+ const allSelected =
+ wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
+ const someSelected = wagons.some((w) => selected.includes(w.id));
+
+ const toggleAll = (checked: boolean) => {
+ setSelected((prev) => {
+ if (checked) {
+ const ids = new Set(prev);
+ wagons.forEach((w) => ids.add(w.id));
+ return [...ids];
+ }
+ const visible = new Set(wagons.map((w) => w.id));
+ return prev.filter((id) => !visible.has(id));
+ });
+ };
+
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
/>
+ {wagons.length ? (
+ toggleAll(e.currentTarget.checked)}
+ />
+ ) : null}
+
{wagonsQuery.isLoading ? (
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
index 6eb2ab9a8..7f60cabe9 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
@@ -31,13 +31,12 @@ const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/**
- * Step one of the Train Builder: give the train its operator code, pick the
- * yard it is being assembled in, and couple at least two locomotives from that
- * yard. Wagons are attached afterwards on the composition page.
+ * Step one of the Train Builder: pick the yard it is being assembled in and
+ * couple at least two locomotives from that yard. The train code is assigned by
+ * the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
- const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState("");
@@ -62,7 +61,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
- setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName("");
@@ -73,9 +71,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
- if (!code.trim() || !yardId || locomotiveIds.length < 2) {
+ if (!yardId || locomotiveIds.length < 2) {
toast({
- title: "Enter a train code, pick a yard, and couple at least two locomotives",
+ title: "Pick a yard and couple at least two locomotives",
variant: "destructive",
});
return;
@@ -89,7 +87,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}
try {
const composition = await build.mutateAsync({
- code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
@@ -125,24 +122,16 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
A train is assembled in one yard: two or more locomotives plus wagons
- standing in that same yard. Wagons are attached on the next screen.
+ standing in that same yard. The train code is assigned automatically;
+ wagons are attached on the next screen.
-
- setCode(e.currentTarget.value)}
- maxLength={32}
- />
- setTrainName(e.currentTarget.value)}
- maxLength={100}
- />
-
+ setTrainName(e.currentTarget.value)}
+ maxLength={100}
+ />
{
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
editable={editable}
busy={busy}
onRemove={onRemove}
+ onMaintenance={onMaintenance}
/>
)}
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
+ /** Detach the wagon and move it to MAINTENANCE status. */
+ onMaintenance: (wagonId: string) => void;
busy?: boolean;
}
@@ -106,6 +110,7 @@ function WagonRow({
editable,
busy,
onRemove,
+ onMaintenance,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -114,6 +119,7 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
+ onMaintenance: (wagonId: string) => void;
}) {
return (
@@ -153,17 +159,30 @@ function WagonRow({
{editable ? (
-
- onRemove(wagon.id)}
- aria-label={`Detach wagon ${wagon.wagonNumber}`}
- >
-
-
-
+
+
+ onMaintenance(wagon.id)}
+ aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
+ >
+
+
+
+
+ onRemove(wagon.id)}
+ aria-label={`Detach wagon ${wagon.wagonNumber}`}
+ >
+
+
+
+
) : null}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
index a288d02c1..f4e65266f 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
@@ -76,12 +76,18 @@ export default function TrainBuilderDetailPage() {
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
+ const maintenanceWagon = useMutation(
+ api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
+ );
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const composition = compositionQuery.data;
const busy =
- assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
+ assignWagons.isPending ||
+ removeWagon.isPending ||
+ maintenanceWagon.isPending ||
+ reorderWagons.isPending;
const withToast = async (action: () => Promise, failTitle: string) => {
try {
@@ -284,6 +290,12 @@ export default function TrainBuilderDetailPage() {
"Could not detach wagon",
)
}
+ onMaintenance={(wagonId) =>
+ void withToast(
+ () => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
+ "Could not send wagon to maintenance",
+ )
+ }
/>
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index 6a35fd49c..e2e0e1d9f 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -1850,6 +1850,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
+ sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
+ "train-builder",
+ "sendWagonToMaintenance",
+ ({ id, wagonId }) =>
+ trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index e6af4c7dc..f78792b81 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -129,7 +129,6 @@ export interface BuiltTrainListResponse {
}
export interface BuildTrainPayload {
- code: string;
/** EXPORT run number — odd, unique across trains (e.g. 8001). */
exportTrainNumber: string;
/** IMPORT run number — even, unique across trains (e.g. 8002). */
@@ -249,6 +248,9 @@ export const trainBuilderService = {
apiClient.post(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete(`${BASE}/${id}/wagons/${wagonId}`),
+ /** Detach a wagon and move it to MAINTENANCE status. */
+ sendWagonToMaintenance: (id: string, wagonId: string) =>
+ apiClient.post(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
disband: (id: string) => apiClient.delete(`${BASE}/${id}`),
From 3416585a01722882813f1b2ca000af088bfee42d Mon Sep 17 00:00:00 2001
From: SennayT
Date: Wed, 15 Jul 2026 13:42:28 +0300
Subject: [PATCH 37/67] add puppeteer support
---
apps/edr-freight-api/Dockerfile | 26 +++++++++++++++++++++++---
1 file changed, 23 insertions(+), 3 deletions(-)
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 984e622db..5051d13d9 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -2,7 +2,27 @@
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
FROM node:24.15.0-alpine AS base
-RUN apk add --no-cache libc6-compat
+
+# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB
+# download at install time. The runner stage installs Alpine's system Chromium.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs).
+# Without these, Puppeteer fails to launch and the code degrades to an
+# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built);
+# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine.
+RUN apk add --no-cache \
+ libc6-compat \
+ chromium \
+ nss \
+ freetype \
+ harfbuzz \
+ ca-certificates \
+ ttf-freefont \
+ font-noto-cjk
+ENV NODE_ENV=production
+# Point Puppeteer at the system Chromium and skip its bundled download.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
@@ -31,8 +51,8 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
-FROM node:24.15.0-alpine AS runner
-RUN apk add --no-cache libc6-compat
+FROM base AS runner
+
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
From f9706c9fb013030f3aa8115ee36e9ac1d940d017 Mon Sep 17 00:00:00 2001
From: SennayT
Date: Wed, 15 Jul 2026 10:51:35 +0000
Subject: [PATCH 38/67] refactor: separate base image configuration into
Dockerfile.base for improved build management
---
apps/edr-freight-api/Dockerfile | 34 ++++-------------------
apps/edr-freight-api/Dockerfile.base | 41 ++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 28 deletions(-)
create mode 100644 apps/edr-freight-api/Dockerfile.base
diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile
index 5051d13d9..3c05c8a9f 100644
--- a/apps/edr-freight-api/Dockerfile
+++ b/apps/edr-freight-api/Dockerfile
@@ -1,34 +1,12 @@
# syntax=docker/dockerfile:1
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
+#
+# The base image (Node + Alpine Chromium/Puppeteer + pnpm) is built and pushed
+# separately — see Dockerfile.base. Override the pinned tag at build time with
+# --build-arg BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:
+ARG BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:node24-alpine
-FROM node:24.15.0-alpine AS base
-
-# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB
-# download at install time. The runner stage installs Alpine's system Chromium.
-ENV PUPPETEER_SKIP_DOWNLOAD=true
-# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs).
-# Without these, Puppeteer fails to launch and the code degrades to an
-# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built);
-# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine.
-RUN apk add --no-cache \
- libc6-compat \
- chromium \
- nss \
- freetype \
- harfbuzz \
- ca-certificates \
- ttf-freefont \
- font-noto-cjk
-ENV NODE_ENV=production
-# Point Puppeteer at the system Chromium and skip its bundled download.
-ENV PUPPETEER_SKIP_DOWNLOAD=true
-ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
-# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
-# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
-ENV PNPM_HOME="/pnpm"
-ENV PATH="$PNPM_HOME:$PATH"
-RUN corepack enable
-WORKDIR /app
+FROM ${BASE_IMAGE} AS base
FROM base AS pruner
COPY . .
diff --git a/apps/edr-freight-api/Dockerfile.base b/apps/edr-freight-api/Dockerfile.base
new file mode 100644
index 000000000..9459958a2
--- /dev/null
+++ b/apps/edr-freight-api/Dockerfile.base
@@ -0,0 +1,41 @@
+# syntax=docker/dockerfile:1
+# Base image for edr-freight-api — Node + Alpine Chromium/Puppeteer + pnpm.
+# Built and pushed separately so app builds pull it from Harbor instead of
+# reinstalling the ~system Chromium toolchain on every build.
+#
+# Build + push (from monorepo root):
+# docker build -f apps/edr-freight-api/Dockerfile.base \
+# -t registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine .
+# docker push registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine
+#
+# Bump the tag whenever Node, Chromium, or the apk set below changes, then
+# update BASE_IMAGE in Dockerfile to match.
+
+FROM node:24.15.0-alpine
+
+# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB
+# download at install time. This stage installs Alpine's system Chromium.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs).
+# Without these, Puppeteer fails to launch and the code degrades to an
+# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built);
+# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine.
+RUN apk add --no-cache \
+ libc6-compat \
+ chromium \
+ nss \
+ freetype \
+ harfbuzz \
+ ca-certificates \
+ ttf-freefont \
+ font-noto-cjk
+ENV NODE_ENV=production
+# Point Puppeteer at the system Chromium and skip its bundled download.
+ENV PUPPETEER_SKIP_DOWNLOAD=true
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
+# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
+# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+RUN corepack enable
+WORKDIR /app
From f13b1d176c91d2bf06239415fd37ae945c205a12 Mon Sep 17 00:00:00 2001
From: SennayT
Date: Wed, 15 Jul 2026 13:57:28 +0300
Subject: [PATCH 39/67] remove no cache when building
---
.github/workflows/deploy.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f0beccd20..44be07550 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -172,7 +172,7 @@ jobs:
run: |
set -euo pipefail
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
- docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
+ docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}"
# Tag with git SHA for rollback capability
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
From 6f8dcaaf4ad1d89f4f0a18db91964bad3e2ed724 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 14:58:25 +0300
Subject: [PATCH 40/67] Comment out altenative schedules
---
.../portal/src/app/booking/results/page.tsx | 122 ++++++++++--------
1 file changed, 70 insertions(+), 52 deletions(-)
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
index 45331e761..4194324d4 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx
@@ -1065,23 +1065,36 @@ export default function ResultsPage() {
}
if (isOneWayNoOutbound) {
- const hasAlternatives = alternativeOutbound.length > 0;
-
return (
{renderClassModal()}
-
-
-
-
No trains available on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"} .
+
+
+
-
router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
- Change date
+
+ No trains available
+
+
+ There are no trains scheduled on{" "}
+
+ {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
+
+ . Try a different date to see available trains.
+
+ router.push(buildSearchUrl())}
+ className="btn-primary inline-flex items-center gap-2"
+ >
+
+ Change Date
+ {/* Alternative Travel Options — commented out for the time being;
+ only the "No trains available" banner above is shown.
{hasAlternatives && (
@@ -1100,6 +1113,7 @@ export default function ResultsPage() {
)}
+ */}
@@ -1233,30 +1247,32 @@ export default function ResultsPage() {
renderScheduleCard(schedule, true),
)}
- {outboundSchedules.length === 0 &&
- alternativeOutbound.length > 0 && (
-
-
-
-
- No trains on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"} .
-
-
router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
- Change dates
-
-
-
-
- Alternative Outbound Options
-
-
-
- {alternativeOutbound.map((schedule: Schedule) =>
- renderScheduleCard(schedule, true, true),
- )}
+ {outboundSchedules.length === 0 && (
+
+
+
+
+ No trains on {searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"} .
+
router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
+ Change dates
+
- )}
+ {/* Alternative Outbound Options — commented out for the time being;
+ only the "No trains" banner above is shown.
+
+
+ Alternative Outbound Options
+
+
+
+ {alternativeOutbound.map((schedule: Schedule) =>
+ renderScheduleCard(schedule, true, true),
+ )}
+
+ */}
+
+ )}
) : (
@@ -1317,30 +1333,32 @@ export default function ResultsPage() {
renderScheduleCard(schedule, false),
)}
- {inboundSchedules.length === 0 &&
- alternativeInbound.length > 0 && (
-
-
-
-
- No trains on {searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"} .
-
-
router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
- Change dates
-
-
-
-
- Alternative Return Options
-
-
-
- {alternativeInbound.map((schedule: Schedule) =>
- renderScheduleCard(schedule, false, true),
- )}
+ {inboundSchedules.length === 0 && (
+
+
+
+
+ No trains on {searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"} .
+
router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
+ Change dates
+
- )}
+ {/* Alternative Return Options — commented out for the time being;
+ only the "No trains" banner above is shown.
+
+
+ Alternative Return Options
+
+
+
+ {alternativeInbound.map((schedule: Schedule) =>
+ renderScheduleCard(schedule, false, true),
+ )}
+
+ */}
+
+ )}
)
) : (
From 7c9594a581bf0519168acf099d5e0f947b02db63 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Wed, 15 Jul 2026 15:25:32 +0300
Subject: [PATCH 41/67] Fix seat reservation
---
.../src/modules/seats/seats.service.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index 708f57792..c85b49bbf 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -280,7 +280,15 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
- const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
+ // Only the raw BLOCKED status (seat pulled out of service — a genuine
+ // cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
+ // against this raw column: the same physical Seat row is reused across every
+ // recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
+ // trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
+ // here would wrongly block a seat that's actually free for this schedule/leg.
+ // The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
+ // authoritative source for whether a seat is actually taken.
+ const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
From 2e22b72e2786d277b651fa4d493d3fcdfb9bf387 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 11:07:45 +0000
Subject: [PATCH 42/67] 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 43/67] 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 44/67] =?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 45/67] =?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 d7d9db9c3a82103fed228b9b12b06ef316d09837 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 14:27:00 +0000
Subject: [PATCH 46/67] changes
---
.../priority-configs.controller.ts | 18 ++
.../services/priority-configs.range.spec.ts | 229 ++++++++++++++++++
.../services/priority-configs.service.ts | 100 +++++++-
.../contracts/ContractActionsToolbar.tsx | 6 +-
.../contracts/ContractApprovalStepsCard.tsx | 54 ++++-
.../ruleEngine/RuleEngineFormDialog.tsx | 9 +-
.../src/hooks/rule-engine/useRuleEngine.ts | 25 +-
.../ruleEngine/RuleEngineResourcePage.tsx | 54 ++++-
.../src/pages/ruleEngine/config/resources.ts | 24 +-
.../src/pages/ruleEngine/priorityRuleRange.ts | 60 +++++
10 files changed, 554 insertions(+), 25 deletions(-)
create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts
create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts
index 99eaabf3f..6424f013e 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
@@ -24,6 +25,23 @@ export class PriorityConfigsController {
return this.service.findAll(query);
}
+ // Static route — must stay above `:id` (Express matches in declaration order).
+ @Get('next-range')
+ @RuleEngineView('priority-configs')
+ @ApiOperation({
+ summary:
+ "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
+ })
+ nextRange(
+ @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
+ @Query('currency') currency?: string,
+ ) {
+ if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) {
+ throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS');
+ }
+ return this.service.nextRange(type, currency ?? null);
+ }
+
@Get(':id')
@RuleEngineView('priority-configs')
@ApiOperation({ summary: 'Get a priority config by ID' })
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts
new file mode 100644
index 000000000..53cb7ed83
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts
@@ -0,0 +1,229 @@
+import { BadRequestException } from '@nestjs/common';
+
+import { PriorityConfig } from '../entities/priority-config.entity';
+import { PriorityConfigsService } from './priority-configs.service';
+
+/**
+ * Contiguous-range rules for priority configs: per type (per currency for
+ * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
+ * must start at the lowest uncovered wagon count. Caps: WAGON 50,
+ * CURRENCY 35, CUSTOMS 15.
+ */
+describe('PriorityConfigsService range validation', () => {
+ const rule = (
+ type: PriorityConfig['type'],
+ min: number,
+ max: number,
+ currency: string | null = null,
+ id = `${type}-${min}-${max}-${currency ?? 'none'}`,
+ ): PriorityConfig =>
+ ({
+ id,
+ type,
+ label: `${min}-${max}`,
+ currency,
+ minWagonCount: min,
+ maxWagonCount: max,
+ }) as PriorityConfig;
+
+ const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => {
+ const repository = {
+ findAll: jest.fn(async ({ where }: { where: { type: string } }) =>
+ rules.filter((r) => r.type === where.type),
+ ),
+ findById: jest.fn(async (id: string) =>
+ rules.find((r) => r.id === id) ?? null,
+ ),
+ };
+ return new PriorityConfigsService(
+ repository as never,
+ undefined as never, // DisplayOrderService — unused by range validation
+ );
+ };
+
+ const attempt = (
+ svc: PriorityConfigsService,
+ input: Partial[0]>,
+ ) =>
+ svc.assertNoRangeCollision({
+ type: 'WAGON',
+ minWagonCount: 1,
+ maxWagonCount: 5,
+ ...input,
+ });
+
+ it('accepts the first WAGON range starting at 1', async () => {
+ await expect(
+ attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('rejects a first range that does not start at 1', async () => {
+ await expect(
+ attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('rejects an exact duplicate (1–5 vs 1–5)', async () => {
+ await expect(
+ attempt(serviceWith([rule('WAGON', 1, 5)]), {
+ minWagonCount: 1,
+ maxWagonCount: 5,
+ }),
+ ).rejects.toThrow(/must start at 6/);
+ });
+
+ it('rejects a partial overlap (4–7 after 1–5)', async () => {
+ await expect(
+ attempt(serviceWith([rule('WAGON', 1, 5)]), {
+ minWagonCount: 4,
+ maxWagonCount: 7,
+ }),
+ ).rejects.toThrow(/must start at 6/);
+ });
+
+ it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => {
+ await expect(
+ attempt(serviceWith([rule('WAGON', 1, 5)]), {
+ minWagonCount: 8,
+ maxWagonCount: 9,
+ }),
+ ).rejects.toThrow(/must start at 6/);
+ });
+
+ it('accepts the contiguous continuation (6–10 after 1–5)', async () => {
+ await expect(
+ attempt(serviceWith([rule('WAGON', 1, 5)]), {
+ minWagonCount: 6,
+ maxWagonCount: 10,
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('after deleting a middle rule, the next range must fill the lowest gap', async () => {
+ // Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6.
+ const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
+ await expect(
+ attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }),
+ ).rejects.toThrow(/must start at 6/);
+ await expect(
+ attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => {
+ const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
+ await expect(
+ attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }),
+ ).rejects.toThrow(/overlaps existing rule/);
+ });
+
+ it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
+ await expect(
+ attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
+ ).rejects.toThrow(/may not exceed 50/);
+ await expect(
+ attempt(serviceWith([]), {
+ type: 'CURRENCY',
+ currency: 'USD',
+ minWagonCount: 1,
+ maxWagonCount: 36,
+ }),
+ ).rejects.toThrow(/may not exceed 35/);
+ await expect(
+ attempt(serviceWith([]), {
+ type: 'CUSTOMS',
+ minWagonCount: 1,
+ maxWagonCount: 16,
+ }),
+ ).rejects.toThrow(/may not exceed 15/);
+ });
+
+ it('rejects any new rule once the chain covers the full range', async () => {
+ await expect(
+ attempt(serviceWith([rule('WAGON', 1, 50)]), {
+ minWagonCount: 51,
+ maxWagonCount: 51,
+ }),
+ ).rejects.toThrow(/may not exceed 50/);
+ await expect(
+ attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
+ type: 'CUSTOMS',
+ minWagonCount: 1,
+ maxWagonCount: 1,
+ }),
+ ).rejects.toThrow(/already cover the full 1–15 range/);
+ });
+
+ it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
+ const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]);
+ // ETB has no rules yet → starts at 1.
+ await expect(
+ attempt(svc, {
+ type: 'CURRENCY',
+ currency: 'ETB',
+ minWagonCount: 1,
+ maxWagonCount: 5,
+ }),
+ ).resolves.toBeUndefined();
+ // USD must continue at 6.
+ await expect(
+ attempt(svc, {
+ type: 'CURRENCY',
+ currency: 'USD',
+ minWagonCount: 1,
+ maxWagonCount: 5,
+ }),
+ ).rejects.toThrow(/must start at 6/);
+ });
+
+ it('excludes the rule being edited from its own contiguity check', async () => {
+ const existing = rule('WAGON', 6, 10, null, 'editing-me');
+ const svc = serviceWith([rule('WAGON', 1, 5), existing]);
+ // Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed.
+ await expect(
+ attempt(svc, {
+ minWagonCount: 6,
+ maxWagonCount: 12,
+ excludeId: 'editing-me',
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('lets an upper rule keep its start while a lower gap exists', async () => {
+ // Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap
+ // must not block editing an upper rule's points or max.
+ const upper = rule('WAGON', 11, 20, null, 'upper');
+ const svc = serviceWith([rule('WAGON', 1, 5), upper]);
+ await expect(
+ attempt(svc, {
+ minWagonCount: 11,
+ maxWagonCount: 25,
+ excludeId: 'upper',
+ }),
+ ).resolves.toBeUndefined();
+ // But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6.
+ await expect(
+ attempt(svc, {
+ minWagonCount: 30,
+ maxWagonCount: 35,
+ excludeId: 'upper',
+ }),
+ ).rejects.toThrow(/must start at 6/);
+ });
+
+ it('reports the next-range prefill for the form', async () => {
+ const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
+ await expect(svc.nextRange('WAGON')).resolves.toEqual({
+ nextMin: 6,
+ maxCap: 50,
+ });
+ await expect(
+ serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
+ ).resolves.toEqual({ nextMin: null, maxCap: 15 });
+ await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
+ nextMin: 1,
+ maxCap: 35,
+ });
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
index 9aaf06985..ff3711e42 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
@@ -10,6 +10,31 @@ import {
} from '../interfaces/priority-configs.repository.interface';
import { DisplayOrderService } from './display-order.service';
+/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
+export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
+ WAGON: 50,
+ CURRENCY: 35,
+ CUSTOMS: 15,
+};
+
+/**
+ * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
+ * must start. Null when the chain is already complete up to the type's cap.
+ */
+function nextRangeStart(
+ rules: Pick[],
+): number | null {
+ const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
+ const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
+ let next = 1;
+ for (const r of sorted) {
+ if (r.minWagonCount > next) break; // gap before this rule — fill it
+ next = Math.max(next, r.maxWagonCount + 1);
+ }
+ if (cap != null && next > cap) return null;
+ return next;
+}
+
@Injectable()
export class PriorityConfigsService {
constructor(
@@ -73,10 +98,13 @@ export class PriorityConfigsService {
}
/**
- * 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.
+ * Range rules per type (and, for CURRENCY rules, per currency):
+ * - ranges never overlap — a booking matches at most one rule per type;
+ * - ranges are contiguous from 1: a new range must START at the lowest
+ * wagon count not yet covered (after 1–5 the next is 6–…; deleting a
+ * middle rule opens a gap and the next create must fill it first);
+ * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
+ * Ranges are inclusive on both ends.
*/
async assertNoRangeCollision(input: {
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@@ -90,13 +118,49 @@ export class PriorityConfigsService {
'Min wagon count cannot be greater than max wagon count',
);
}
- const siblings = await this.repository.findAll({
- where: { type: input.type },
- });
- const clash = siblings.find(
+ const cap = RANGE_CAPS[input.type];
+ if (input.maxWagonCount > cap) {
+ throw new BadRequestException(
+ `${input.type} ranges may not exceed ${cap} — ` +
+ `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`,
+ );
+ }
+
+ const siblings = (
+ await this.repository.findAll({ where: { type: input.type } })
+ ).filter(
(s) =>
s.id !== input.excludeId &&
- (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
+ (input.type !== 'CURRENCY' ||
+ (s.currency ?? null) === (input.currency ?? null)),
+ );
+
+ const expectedStart = nextRangeStart(siblings);
+ // An edited rule may always KEEP its current start (so a gap lower in the
+ // chain never blocks editing an upper rule's points/max) — or move down to
+ // fill that lowest gap.
+ const currentStart = input.excludeId
+ ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
+ : null;
+ if (expectedStart == null && currentStart == null) {
+ throw new BadRequestException(
+ `${input.type} rules already cover the full 1–${cap} range — ` +
+ 'delete or shrink an existing rule first.',
+ );
+ }
+ if (
+ input.minWagonCount !== expectedStart &&
+ input.minWagonCount !== currentStart
+ ) {
+ throw new BadRequestException(
+ `The next ${input.type} range must start at ${expectedStart} ` +
+ `(ranges are contiguous — no gaps, no overlaps). ` +
+ `You entered ${input.minWagonCount}–${input.maxWagonCount}.`,
+ );
+ }
+
+ const clash = siblings.find(
+ (s) =>
input.minWagonCount <= s.maxWagonCount &&
input.maxWagonCount >= s.minWagonCount,
);
@@ -109,6 +173,24 @@ export class PriorityConfigsService {
}
}
+ /**
+ * Where the next range for a type/currency must start, and the type's
+ * ceiling — feeds the create form so the min field is auto-filled and
+ * locked. `nextMin` is null when the chain already covers 1..cap.
+ */
+ async nextRange(
+ type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
+ currency?: string | null,
+ ): Promise<{ nextMin: number | null; maxCap: number }> {
+ const siblings = (
+ await this.repository.findAll({ where: { type } })
+ ).filter(
+ (s) =>
+ type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
+ );
+ return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
+ }
+
async remove(id: string): Promise {
await this.findById(id);
await this.repository.softDelete(id);
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 a7187638e..0aeb624b3 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx
@@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
+ FileCheck,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
- Sparkles,
XCircle,
Zap,
} from "lucide-react";
@@ -180,7 +180,7 @@ export function ContractActionsToolbar({
documentGenerated ? (
) : (
-
+
)
}
loading={mutations.generateContract.isPending}
@@ -196,7 +196,7 @@ export function ContractActionsToolbar({
fullWidth
variant="light"
color="orange"
- leftSection={ }
+ leftSection={ }
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx
index 044c729ee..eed6c9e3a 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
-import { Check, ShieldCheck, X } from "lucide-react";
+import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState(null);
+ const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState(null);
@@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
+ // Approvers must review the GENERATED contract document before approving. If
+ // it has not been generated yet, block the approval and tell staff to generate
+ // it first (via "Generate contract" in Staff actions) — mirrors the server
+ // guard so the user sees a clear reason, not a generic failure toast.
+ const documentGenerated = Boolean(contract.contractGeneratedAt);
+
const openApprove = (step: Freight.IContractApprovalStep) => {
+ if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
+ setNeedsGenerateOpen(true);
+ return;
+ }
setPendingStep(step);
setConfirmOpen(true);
};
@@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({
+ setNeedsGenerateOpen(false)}
+ title={
+
+
+ Generate the contract first
+
+ }
+ radius="md"
+ centered
+ >
+
+
+ The contract document for{" "}
+
+ {contract.reference}
+ {" "}
+ has not been generated yet. Approvers must review the generated
+ document before it can be approved.
+
+
+ Use{" "}
+
+ Generate contract
+ {" "}
+ in the Staff actions panel — edit the articles first if needed — then
+ return here to approve.
+
+
+ }
+ onClick={() => setNeedsGenerateOpen(false)}
+ >
+ Got it
+
+
+
+
+
= {};
for (const field of visibleFields) {
- const raw = values[field.name];
+ // Derived fields always submit their computed value — never stale state.
+ const raw = field.computeValue
+ ? (field.computeValue(values) ?? "")
+ : values[field.name];
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
@@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({
}
const isNumber = field.type === "number";
+ const computed = field.computeValue ? field.computeValue(values) : undefined;
return (
{
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index b3a6c49fd..5ae7bac72 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
/**
* Priority-rule approval workflow. Every create/update/delete of a priority
* config is SUBMITTED as a change request; an approver applies or rejects it.
- * Error toasts surface the backend message so range-collision rejections
- * ("1–5 overlaps existing rule …") reach the user verbatim.
+ * Backend messages (range collision, gap, ceiling) surface verbatim — through
+ * `onErrorMessage` (the page shows them in a modal) or a toast as fallback.
*/
-export const usePriorityRuleWorkflow = (enabled: boolean) => {
+export const usePriorityRuleWorkflow = (
+ enabled: boolean,
+ onErrorMessage?: (message: string) => void,
+) => {
const qc = useQueryClient();
const backendMessage = (err: unknown, fallback: string) => {
@@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
return msg || fallback;
};
+ const showError = (err: unknown, fallback: string) => {
+ const message = backendMessage(err, fallback);
+ if (onErrorMessage) onErrorMessage(message);
+ else toast.error(message);
+ };
+
const pending = useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
@@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
});
await invalidateRuleEngineList(qc, "priority-configs");
+ // The full order-list backs the auto-filled min field — keep it fresh too.
+ await qc.invalidateQueries({
+ queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"),
+ });
};
const submit = useMutation({
@@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change submitted for approval — the team has been notified");
await invalidate();
},
- onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
+ onError: (err) => showError(err, "Failed to submit change"),
});
const approve = useMutation({
@@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change approved and applied");
await invalidate();
},
- onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
+ onError: (err) => showError(err, "Failed to approve change"),
});
const reject = useMutation({
@@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change rejected");
await invalidate();
},
- onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
+ onError: (err) => showError(err, "Failed to reject change"),
});
return { pending, submit, approve, reject };
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 95c4c22ac..af8b65a50 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -19,6 +19,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 { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => {
);
// Priority rules never mutate directly: changes are filed for approval and a
- // pending queue renders above the table.
+ // pending queue renders above the table. Validation errors (range collision,
+ // gap, ceiling) surface in a modal so the text is impossible to miss.
const isPriorityRules = config?.slug === "priority-configs";
+ const [priorityError, setPriorityError] = useState(null);
const priorityWorkflow = usePriorityRuleWorkflow(
Boolean(isPriorityRules && canView),
+ setPriorityError,
);
const editingId = editing?.id ? String(editing.id) : undefined;
@@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
+ // Full rule list backing the auto-filled "min wagon count": the next range
+ // always continues the chain for the selected type (per currency), so the
+ // form needs every existing rule, not the current page.
+ const { data: allPriorityRules } = useRuleEngineOrderList(
+ config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
+ Boolean(isPriorityRules && formOpen),
+ config?.orderConfig?.field,
+ );
+
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
+ if (isPriorityRules && field.name === "minWagonCount") {
+ return {
+ ...field,
+ // Editing keeps the rule's own start (a lower gap never forces it to
+ // move); creating always continues the chain / fills the lowest gap.
+ computeValue: (values: Record) =>
+ editing?.minWagonCount != null
+ ? Number(editing.minWagonCount)
+ : nextPriorityRangeStart(
+ allPriorityRules ?? [],
+ String(values.type ?? ""),
+ !values.currency || values.currency === RULE_ENGINE_SELECT_NONE
+ ? null
+ : String(values.currency),
+ editingId,
+ ),
+ };
+ }
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return {
...field,
@@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
- }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
+ }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => {
/>
) : null}
+ setPriorityError(null)}
+ title="Cannot save priority rule"
+ centered
+ size="md"
+ >
+
+
+ {priorityError}
+
+
+ setPriorityError(null)}>
+ OK
+
+
+
+
+
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index bc17fb47a..4423b4f93 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -61,6 +61,13 @@ export interface FormFieldDef {
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
*/
getInitialValue?: (record: Record) => unknown;
+ /**
+ * Fully derived field: its value is computed from the live form values on
+ * every render and the input is locked. Used for the priority-rule min
+ * wagon count, which always continues the previous range for the selected
+ * type. Return null/undefined to leave the field empty (e.g. chain full).
+ */
+ computeValue?: (values: Record) => number | string | null;
}
export interface RuleEngineOrderConfig {
@@ -356,8 +363,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
- { name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
- { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
+ {
+ name: "minWagonCount",
+ label: "Min wagon count",
+ type: "number",
+ required: true,
+ disabled: true,
+ description: "Auto-filled — continues the previous range for the selected type",
+ },
+ {
+ name: "maxWagonCount",
+ label: "Max wagon count",
+ type: "number",
+ required: true,
+ description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
+ },
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
],
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
new file mode 100644
index 000000000..f387fc8dc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts
@@ -0,0 +1,60 @@
+/**
+ * Client mirror of the backend's contiguous-range rules for priority configs
+ * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
+ * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
+ * range always starts at the lowest uncovered wagon count. The backend
+ * re-validates on submit AND on approval; this only drives the form prefill.
+ */
+
+export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
+
+/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
+export const PRIORITY_RANGE_CAPS: Record = {
+ WAGON: 50,
+ CURRENCY: 35,
+ CUSTOMS: 15,
+};
+
+export interface PriorityRangeRule {
+ id?: unknown;
+ type?: unknown;
+ currency?: unknown;
+ minWagonCount?: unknown;
+ maxWagonCount?: unknown;
+}
+
+/**
+ * Where the next range for `type` (+`currency`) must start, excluding
+ * `excludeId` (the rule being edited). Null when the chain already covers
+ * 1..cap — no further rule fits.
+ */
+export function nextPriorityRangeStart(
+ rules: PriorityRangeRule[],
+ type: string,
+ currency: string | null | undefined,
+ excludeId?: string,
+): number | null {
+ const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
+ if (!cap) return null;
+
+ const scoped = rules
+ .filter(
+ (r) =>
+ String(r.type ?? "") === type &&
+ (excludeId === undefined || String(r.id ?? "") !== excludeId) &&
+ (type !== "CURRENCY" ||
+ String(r.currency ?? "") === String(currency ?? "")),
+ )
+ .map((r) => ({
+ min: Number(r.minWagonCount ?? 0),
+ max: Number(r.maxWagonCount ?? 0),
+ }))
+ .sort((a, b) => a.min - b.min);
+
+ let next = 1;
+ for (const r of scoped) {
+ if (r.min > next) break; // gap before this rule — fill it first
+ next = Math.max(next, r.max + 1);
+ }
+ return next > cap ? null : next;
+}
From 2044312d93087e9a8444d71dba913c42ccd63da8 Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Wed, 15 Jul 2026 14:51:21 +0000
Subject: [PATCH 47/67] 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 48/67] 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 7bbd34f159d5ec58d97d0d18ba5b25b1593b6cd6 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 19:18:35 +0300
Subject: [PATCH 49/67] Currency issue resolution
---
.../src/modules/bookings/bookings.service.ts | 20 ++++++++++---------
1 file changed, 11 insertions(+), 9 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 cffa01c4b..563a095aa 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -321,7 +321,7 @@ export class BookingsService {
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
- currency: b.currency || 'ETB',
+ currency: b.currency || null,
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
@@ -554,7 +554,8 @@ export class BookingsService {
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
- totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
+ totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
+ currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
@@ -577,7 +578,7 @@ export class BookingsService {
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
- totalMinor: b.totalMinor, currency: b.currency || 'ETB',
+ totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
@@ -723,7 +724,7 @@ export class BookingsService {
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
- currency: b.currency || 'ETB',
+ currency: b.currency || b.displayCurrency,
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
@@ -1314,7 +1315,7 @@ export class BookingsService {
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayTotalMinor,
},
};
}
@@ -1513,7 +1514,7 @@ export class BookingsService {
combinedBaseFareMinor: combinedBase,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayTotalMinor,
},
};
}
@@ -1810,7 +1811,7 @@ export class BookingsService {
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
- currency: pkgBooking.currency || 'ETB',
+ currency: pkgBooking.currency || pkgBooking.displayCurrency,
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
@@ -1870,7 +1871,8 @@ export class BookingsService {
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
- totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
+ totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
+ currency: booking.displayCurrency,
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,
@@ -1973,7 +1975,7 @@ export class BookingsService {
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } });
- return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
+ return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
}
async update(id: string, dto: any) {
From 8bda06b2267c1ae3ff585d430dc44831ca9b7a44 Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 19:50:32 +0300
Subject: [PATCH 50/67] Currency issue resolution
---
.../src/modules/bookings/bookings.service.ts | 5 +-
.../modules/bookings/guest-booking.service.ts | 385 +++++++++---------
2 files changed, 198 insertions(+), 192 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 563a095aa..2a9de63d2 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -881,6 +881,7 @@ export class BookingsService {
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1058,6 +1059,7 @@ export class BookingsService {
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1250,6 +1252,7 @@ export class BookingsService {
status: 'PENDING_PAYMENT',
bookingType: 'TRANSIT',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
@@ -1459,7 +1462,7 @@ export class BookingsService {
destinationStationId: dto.leg2DestinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ROUND_TRIP_TRANSIT',
- totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
+ totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
// Outbound transit leg-2
leg2ScheduleId: dto.leg2ScheduleId,
leg2OriginStationId: dto.transitStationId,
diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
index 259beb404..821db5177 100644
--- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts
@@ -18,7 +18,7 @@ function generateRef(): string {
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
-const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964'];
+const ETH_MOBILE_PREFIXES = ['911', '912', '913', '914', '915', '916', '917', '921', '922', '923', '924', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '961', '962', '963', '964'];
function generateEthiopianPhone(): string {
const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)];
@@ -50,7 +50,7 @@ export class GuestBookingService {
private passengerAuthService: PassengerAuthService,
private fareEngine: FareEngineService,
private eventEmitter: EventEmitter2,
- ) {}
+ ) { }
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
@@ -70,8 +70,8 @@ export class GuestBookingService {
});
}
}
- if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
- if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
+ if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
+ if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
return this.createGuestOneWayBooking(dto, req);
}
@@ -123,8 +123,8 @@ export class GuestBookingService {
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
- passenger.nationality === 'ETHIOPIAN' ||
- passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
@@ -278,13 +278,14 @@ export class GuestBookingService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
totalMinor: resolvedTotalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
- userAgent: dto.deviceId,
+ userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
seats: {
@@ -350,7 +351,7 @@ export class GuestBookingService {
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
]);
if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found');
- if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
+ if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found');
// Validate passengers have returnSeatId
for (const p of dto.passengers) {
@@ -369,7 +370,7 @@ export class GuestBookingService {
}),
]);
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
- if (!returnSchedule) throw new NotFoundException('Return schedule not found');
+ if (!returnSchedule) throw new NotFoundException('Return schedule not found');
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
@@ -379,19 +380,19 @@ export class GuestBookingService {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
};
- const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
- const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
- const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
- const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
- const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
- const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
+ const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
+ const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
+ const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
+ const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
+ const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
+ const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
- if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
+ if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`;
- const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
- const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
- const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
+ const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`;
+ const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`;
+ const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`;
// Process passengers (verify identity once — same person travels both legs)
const passengersData: any[] = [];
@@ -409,16 +410,16 @@ export class GuestBookingService {
let nationality = passenger.nationality;
const isEthiopian = passenger.nationality === 'Ethiopian' ||
- passenger.nationality === 'ETHIOPIAN' ||
- passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
+ passenger.nationality === 'ETHIOPIAN' ||
+ passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
if (passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
- passengerName = verification.passengerData?.fullName || passengerName;
+ passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
- verifaydaData = verification.passengerData?.profileData;
+ verifaydaData = verification.passengerData?.profileData;
}
nationality = 'Ethiopian';
} else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) {
@@ -450,7 +451,7 @@ export class GuestBookingService {
returnBaseFare = tier.priceMinor - halfMinor;
paidChildrenCount = childCount;
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
- returnChildUnitFare = Math.round(returnBaseFare * 0.1);
+ returnChildUnitFare = Math.round(returnBaseFare * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
[outboundBaseFare, returnBaseFare] = await Promise.all([
@@ -459,11 +460,11 @@ export class GuestBookingService {
]);
paidChildrenCount = Math.max(0, childCount - 1);
outboundChildUnitFare = outboundBaseFare;
- returnChildUnitFare = returnBaseFare;
+ returnChildUnitFare = returnBaseFare;
}
- const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
- const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
- const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
+ const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
+ const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
+ const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
if (dto.promoCode) {
@@ -490,16 +491,16 @@ export class GuestBookingService {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
- outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
- returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
+ outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
+ returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
} else if (isPackageRoundTrip) {
outboundFareMinor = 0;
- returnFareMinor = 0;
+ returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
- if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
- else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
+ if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
+ else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -527,69 +528,70 @@ export class GuestBookingService {
// Create booking with outbound seats; return seats confirmed separately
const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id);
- const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
+ const returnSeatIds = dto.passengers.map(p => p.returnSeatId!);
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.destinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'ROUND_TRIP',
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
- returnScheduleId: dto.returnScheduleId,
- returnOriginStationId: dto.returnOriginStationId,
- returnDestinationStationId: dto.returnDestinationStationId,
- returnHoldId: dto.returnHoldId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnHoldId: dto.returnHoldId,
returnSeatClassId,
- returnLegStatus: 'NEITHER_USED',
+ returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersWithFares.map((p) => ({
- seat: { connect: { id: p.seatId } },
- leg: 1,
- scheduleId: dto.scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.outboundFareMinor,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.outboundFareMinor,
displayCurrency,
})),
...passengersWithFares.map((p) => ({
- seat: { connect: { id: p.returnSeatId } },
- leg: 2,
- scheduleId: dto.returnScheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.returnSeatId } },
+ leg: 2,
+ scheduleId: dto.returnScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.returnFareMinor,
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.returnFareMinor,
displayCurrency,
})),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -608,16 +610,16 @@ export class GuestBookingService {
iamUserId,
fareBreakdown: {
outboundBaseFareMinor: outboundBaseFare,
- returnBaseFareMinor: returnBaseFare,
+ returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
- freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
+ freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,
- taxesFeesMinor: taxesMinor,
+ taxesFeesMinor: taxesMinor,
totalMinor,
- currency: 'ETB',
+ currency: displayCurrency,
displayCurrency,
displayTotalMinor,
},
@@ -658,9 +660,9 @@ export class GuestBookingService {
}
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
- const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
- const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule');
@@ -695,9 +697,9 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
- const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
+ const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
- const paidChildrenCount = Math.max(0, childCount - 1);
+ const paidChildrenCount = Math.max(0, childCount - 1);
const [leg1BaseFare, leg2BaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId,
@@ -710,9 +712,9 @@ export class GuestBookingService {
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
]);
- const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
- const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
- const combinedBase = leg1Total + leg2Total;
+ const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
+ const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount;
+ const combinedBase = leg1Total + leg2Total;
let discountMinor = 0;
if (dto.promoCode) {
@@ -734,62 +736,63 @@ export class GuestBookingService {
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.leg2DestinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'TRANSIT',
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.leg2DestinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'TRANSIT',
totalMinor,
+ currency: displayCurrency,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
- leg2ScheduleId: dto.leg2ScheduleId,
- leg2OriginStationId: dto.transitStationId,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
- leg2SeatClassId: leg2SeatClassId,
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ leg2SeatClassId: leg2SeatClassId,
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
...passengersData.map(p => ({
- seat: { connect: { id: p.seatId } },
- leg: 1,
- scheduleId: dto.scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.seatId } },
+ leg: 1,
+ scheduleId: dto.scheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0),
displayCurrency,
})),
...passengersData.map(p => ({
- seat: { connect: { id: p.leg2SeatId! } },
- leg: 2,
- scheduleId: dto.leg2ScheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ seat: { connect: { id: p.leg2SeatId! } },
+ leg: 2,
+ scheduleId: dto.leg2ScheduleId,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0),
displayCurrency,
})),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -821,15 +824,15 @@ export class GuestBookingService {
private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) {
if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId ||
- !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
- !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
+ !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId ||
+ !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) {
throw new BadRequestException(
'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields',
);
}
for (const p of dto.passengers) {
- if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
- if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
+ if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`);
+ if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`);
if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`);
}
@@ -840,19 +843,19 @@ export class GuestBookingService {
this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }),
this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }),
]);
- if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
- if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
+ if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired');
+ if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired');
if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired');
if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired');
const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([
- this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
- this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
+ this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }),
]);
- if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
- if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
+ if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found');
+ if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found');
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
@@ -860,16 +863,16 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
- const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
- const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
- const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
- const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
+ const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
+ const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
+ const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId);
const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
- const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
+ const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId);
- const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
- if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
- if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
+ const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId);
+ if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found');
+ if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found');
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found');
@@ -901,21 +904,21 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
- const nat = passengersData[0]?.nationality;
- const paidChildren = Math.max(0, childCount - 1);
- const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
- const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
- const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
+ const nat = passengersData[0]?.nationality;
+ const paidChildren = Math.max(0, childCount - 1);
+ const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId;
+ const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId;
+ const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
- this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
- this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
- this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
- this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
+ this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
+ this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
+ this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
+ this.getBaseFare(dto.returnLeg2ScheduleId!, retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
]);
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
- (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
+ (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
@@ -923,8 +926,8 @@ export class GuestBookingService {
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
- const taxesMinor = Math.round(combinedBase * 0.05);
- const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
+ const taxesMinor = Math.round(combinedBase * 0.05);
+ const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
@@ -933,58 +936,58 @@ export class GuestBookingService {
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({
- seat: { connect: { id: seatId } },
+ seat: { connect: { id: seatId } },
leg,
scheduleId,
- passengerName: p.passengerName,
- dateOfBirth: p.dateOfBirth,
+ passengerName: p.passengerName,
+ dateOfBirth: p.dateOfBirth,
passengerCategory: p.category,
- idDocumentType: p.idDocumentType,
- passportNumber: p.passportNumber,
- passportCountry: p.passportCountry,
+ idDocumentType: p.idDocumentType,
+ passportNumber: p.passportNumber,
+ passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
- verifaydaData: p.verifaydaData || undefined,
- fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
+ verifaydaData: p.verifaydaData || undefined,
+ fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0),
displayCurrency,
});
const booking = await this.prisma.booking.create({
data: {
- bookingRef: generateRef(),
- passengerId: guestPassengerId,
- scheduleId: dto.scheduleId,
- originStationId: dto.originStationId,
- destinationStationId: dto.returnLeg2DestinationStationId,
- status: 'PENDING_PAYMENT',
- bookingType: 'ROUND_TRIP_TRANSIT',
- totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
- leg2ScheduleId: dto.leg2ScheduleId,
- leg2OriginStationId: dto.transitStationId,
- leg2DestinationStationId: dto.leg2DestinationStationId,
- leg2SeatClassId: obL2ClassId,
- returnScheduleId: dto.returnScheduleId,
- returnOriginStationId: dto.returnOriginStationId,
- returnDestinationStationId: dto.returnDestinationStationId,
- returnSeatClassId: retL1ClassId,
- returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
- returnLeg2OriginStationId: dto.returnTransitStationId,
- returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
- returnLeg2SeatClassId: retL2ClassId,
- returnLegStatus: 'NEITHER_USED',
- userAgent: dto.deviceId,
- contactEmail: passengersData[0]?.email || null,
- contactPhone: passengersData[0]?.phone || null,
+ bookingRef: generateRef(),
+ passengerId: guestPassengerId,
+ scheduleId: dto.scheduleId,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.returnLeg2DestinationStationId,
+ status: 'PENDING_PAYMENT',
+ bookingType: 'ROUND_TRIP_TRANSIT',
+ totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
+ leg2ScheduleId: dto.leg2ScheduleId,
+ leg2OriginStationId: dto.transitStationId,
+ leg2DestinationStationId: dto.leg2DestinationStationId,
+ leg2SeatClassId: obL2ClassId,
+ returnScheduleId: dto.returnScheduleId,
+ returnOriginStationId: dto.returnOriginStationId,
+ returnDestinationStationId: dto.returnDestinationStationId,
+ returnSeatClassId: retL1ClassId,
+ returnLeg2ScheduleId: dto.returnLeg2ScheduleId,
+ returnLeg2OriginStationId: dto.returnTransitStationId,
+ returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
+ returnLeg2SeatClassId: retL2ClassId,
+ returnLegStatus: 'NEITHER_USED',
+ userAgent: dto.deviceId,
+ contactEmail: passengersData[0]?.email || null,
+ contactPhone: passengersData[0]?.phone || null,
seats: {
create: [
- ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
- ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
- ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
- ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)),
+ ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
],
},
} as any,
include: {
- seats: { include: { seat: { include: { coach: true } } } },
+ seats: { include: { seat: { include: { coach: true } } } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
},
});
@@ -1006,14 +1009,14 @@ export class GuestBookingService {
fareBreakdown: {
outboundLeg1FareMinor: obL1Fare,
outboundLeg2FareMinor: obL2Fare,
- returnLeg1FareMinor: retL1Fare,
- returnLeg2FareMinor: retL2Fare,
+ returnLeg1FareMinor: retL1Fare,
+ returnLeg2FareMinor: retL2Fare,
adultCount, childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount: paidChildren,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
- currency: 'ETB', displayCurrency, displayTotalMinor,
+ currency: displayCurrency, displayCurrency, displayTotalMinor,
},
};
}
@@ -1042,7 +1045,7 @@ export class GuestBookingService {
const guestPassenger = await this.prisma.passenger.create({ data: {} });
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
-
+
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
}
@@ -1052,7 +1055,7 @@ export class GuestBookingService {
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
}
-
+
await this.prisma.travelerProfile.create({
data: {
passengerId,
@@ -1130,8 +1133,8 @@ export class GuestBookingService {
}),
]);
- const premiumMinor = seatClass?.premiumMinor ?? 0;
- const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
+ const premiumMinor = seatClass?.premiumMinor ?? 0;
+ const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
const priorities = [
{ tripId: scheduleId, route: segmentRoute, nationality },
From 2ffb2f9f6037e9a2ae9edd5b967eefbbefa972ed Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 20:05:55 +0300
Subject: [PATCH 51/67] 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 2d2fcfbda9de3e1845bfb36193d427e8572dddfe Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Wed, 15 Jul 2026 21:11:23 +0300
Subject: [PATCH 52/67] Seat block issue resolution
---
.../src/modules/seats/seats.service.ts | 27 +++++++++++++------
.../backoffice/src/app/seats/page.tsx | 25 ++++++++++-------
2 files changed, 35 insertions(+), 17 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
index c85b49bbf..340900fdd 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts
@@ -224,16 +224,27 @@ export class SeatsService {
}
}
- const availability = await this.segmentsService.getSeatAvailabilityMap(
- scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
- );
+ const [availability, persistedSeats] = await Promise.all([
+ this.segmentsService.getSeatAvailabilityMap(
+ scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
+ ),
+ this.prisma.seat.findMany({
+ where: { id: { in: seatIds } },
+ select: { id: true, status: true },
+ }),
+ ]);
+
+ const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status]));
- // Every requested seat defaults to AVAILABLE — this also guards against a stale
- // persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding
- // through getSeatMap's own fallback, since that fallback only triggers when this
- // map has no entry at all for a given seat.
for (const seatId of seatIds) {
- statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
+ const persisted = persistedStatus.get(seatId);
+ // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins —
+ // always honour them regardless of hold/booking state.
+ if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') {
+ statusMap.set(seatId, persisted!);
+ } else {
+ statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
+ }
}
return statusMap;
diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
index 413383b7a..9c9ee3286 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
@@ -35,6 +35,7 @@ export default function SeatsPage() {
queryKey: ['seatmap', selectedSchedule],
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
enabled: !!selectedSchedule,
+ staleTime: 0,
});
const { data: coachTypesData } = useQuery({
@@ -49,6 +50,7 @@ export default function SeatsPage() {
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
queryKey: ['routeCoaches', selectedRoute],
+ staleTime: 0,
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
@@ -79,10 +81,15 @@ export default function SeatsPage() {
enabled: !!selectedRoute,
});
+ const invalidateSeatData = () => {
+ queryClient.refetchQueries({ queryKey: ['seatmap', selectedSchedule] });
+ queryClient.refetchQueries({ queryKey: ['routeCoaches', selectedRoute] });
+ };
+
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowBlockModal(false);
setSelectedSeat(null);
setBlockReason('');
@@ -92,14 +99,14 @@ export default function SeatsPage() {
const unblockMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
},
});
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowRemoveModal(false);
setSelectedSeat(null);
},
@@ -108,7 +115,7 @@ export default function SeatsPage() {
const undoRemoveMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
},
});
@@ -116,7 +123,7 @@ export default function SeatsPage() {
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
seatsApi.setMaintenance(seatId, reason),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowMaintenanceModal(false);
setSelectedSeat(null);
setMaintenanceReason('');
@@ -125,7 +132,7 @@ export default function SeatsPage() {
const clearMaintenanceMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }),
+ onSuccess: () => invalidateSeatData(),
});
const schedules = schedulesData?.items || schedulesData?.data || [];
@@ -139,7 +146,7 @@ export default function SeatsPage() {
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
@@ -153,7 +160,7 @@ export default function SeatsPage() {
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['seatmap'] });
+ invalidateSeatData();
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
},
@@ -1015,7 +1022,7 @@ function SeatIcon({
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
- const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
+ const canMaintenance = false;
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
return (
From 9cf71e7e7a56ed515392eccfc3342a162ad9e597 Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Wed, 15 Jul 2026 21:23:10 +0300
Subject: [PATCH 53/67] 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 54/67] 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 55/67] 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 56/67] 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,
);
}
From fc44eee25ece960f2cb1574302be31e2de027fdb Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 20:14:39 +0000
Subject: [PATCH 57/67] implement update train details feature: add DTO,
service method, and UI modal for editing train name and run numbers
---
.../2260000000000-AddClearanceFeePayment.ts | 44 ++++
.../bookings/booking-transition.service.ts | 6 +
.../bookings/entities/booking.entity.ts | 5 +
.../contracts/clearance-fee.service.ts | 215 ++++++++++++++++++
.../contract-booking.completion.spec.ts | 1 +
.../contract-booking.consolidation.spec.ts | 1 +
.../contracts/contract-booking.service.ts | 13 +-
.../contracts/contract-clearance.service.ts | 5 +
.../contracts/contract-notifier.service.ts | 20 ++
.../contracts/contract-pricing.service.ts | 33 ++-
.../contracts/contract-transition.service.ts | 15 +-
.../src/modules/contracts/contracts.module.ts | 2 +
.../entities/contract-rate-snapshot.entity.ts | 7 +
.../contracts/entities/contract.entity.ts | 6 +
.../rule-engine/entities/rate-type.util.ts | 2 +
.../rule-engine/entities/rate-unit.util.ts | 3 +
.../rule-engine/entities/rate.entity.ts | 4 +
.../trains/dto/update-train-details.dto.ts | 29 +++
.../modules/trains/entities/train.entity.ts | 2 +-
.../trains/train-builder.controller.ts | 13 ++
.../modules/trains/train-builder.service.ts | 48 ++++
.../trainBuilder/EditTrainDetailsModal.tsx | 123 ++++++++++
.../bookings/booking-status.config.ts | 7 +-
.../contracts/contract-status.config.ts | 12 +
.../src/pages/ruleEngine/config/resources.ts | 4 +
.../trainBuilder/TrainBuilderListPage.tsx | 26 +++
.../backoffice/src/services/api.ts | 13 ++
.../src/services/trainBuilder.service.ts | 11 +
.../backoffice/src/types/booking.ts | 1 +
.../ContractCustomerAction.tsx | 12 +
.../deriveContractCustomerAction.ts | 19 ++
.../portal/src/pages/MyPortalPage/actions.ts | 27 ++-
.../components/ActionNeededSection.tsx | 15 +-
.../src/pages/MyPortalPage/constants.ts | 13 ++
.../BookingDetailPage/ReadonlyBookingView.tsx | 27 ++-
.../clearance/BookingActionButton.tsx | 16 ++
.../bookings/clearance/bookingNextAction.ts | 6 +
.../payments/PayClearanceFeeButton.tsx | 133 +++++++++++
.../pages/contracts/ContractDetailPage.tsx | 17 ++
.../src/pages/contracts/NewContractPage.tsx | 6 +
.../contracts/NewShipmentRequestPage.tsx | 5 +-
.../src/pages/contracts/contract-ui.tsx | 4 +
packages/types/src/freight/contracts.ts | 7 +
packages/types/src/freight/index.ts | 6 +-
44 files changed, 970 insertions(+), 14 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts
create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx
create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx
diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts
new file mode 100644
index 000000000..c0dc2c818
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts
@@ -0,0 +1,44 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Prepaid customs clearance service fee (Path B):
+ * - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
+ * fee line so it is billed via its own clearance invoice and excluded from
+ * shipment booking totals;
+ * - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
+ * settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
+ * - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
+ * fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
+ * All nullable/defaulted — existing rows are untouched and keep today's flow.
+ */
+export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_rate_snapshots
+ ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS clearance_fee_paid_at;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.contracts
+ DROP COLUMN IF EXISTS clearance_fee_paid_at;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.contract_rate_snapshots
+ DROP COLUMN IF EXISTS is_clearance;
+ `);
+ }
+}
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 698759901..581ad917c 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
@@ -1,5 +1,6 @@
import {
BadRequestException,
+ ConflictException,
forwardRef,
Inject,
Injectable,
@@ -714,6 +715,11 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise {
const booking = await this.bookingsService.findById(bookingId);
+ if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
+ throw new ConflictException(
+ "The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
+ );
+ }
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 79d15069b..f74cb515e 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow).
+ 'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
@@ -521,6 +522,10 @@ export class Booking extends BaseEntity {
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null;
+ /** When the prepaid customs clearance service fee settled (GENERAL + customs). */
+ @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
+ clearanceFeePaidAt?: Date | null;
+
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null;
diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
new file mode 100644
index 000000000..3d5dc617b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
@@ -0,0 +1,215 @@
+import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
+import { OnEvent } from '@nestjs/event-emitter';
+import { Freight } from '@edr/types';
+
+import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
+import { Invoice } from '../billing/entities/invoice.entity';
+import { BookingsRepository } from '../bookings/bookings.repository';
+import { Booking } from '../bookings/entities/booking.entity';
+import { ContractPricingBreakdown } from './contract-pricing.service';
+import { ContractNotifierService } from './contract-notifier.service';
+import { ContractsRepository } from './contracts.repository';
+import { Contract } from './entities/contract.entity';
+
+/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
+export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
+/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
+export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
+
+/**
+ * The prepaid customs clearance service fee (Path B) — the GL service charge,
+ * separate from both freight (booking invoice) and duty/tax (paid offline).
+ * Issued as its own `clearance`-source invoice and paid BEFORE the clearance
+ * document step opens and before GL touches the file:
+ * - ONE_TIME: once per contract, at staff counter-sign
+ * (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
+ * - GENERAL: once per shipment request, on the initiated booking instance
+ * (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
+ * The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
+ * customers pay what their contract shows, not the live rate of the day.
+ */
+@Injectable()
+export class ClearanceFeeService {
+ private readonly logger = new Logger(ClearanceFeeService.name);
+
+ constructor(
+ private readonly billing: BillingService,
+ private readonly contractsRepository: ContractsRepository,
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly notifier: ContractNotifierService,
+ ) {}
+
+ /** The frozen flat fee for a contract; falls back to the pricing breakdown. */
+ private async feeAmountOrNull(
+ contract: Contract,
+ ): Promise<{ amount: number; currency: string } | null> {
+ const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
+ const snapshot = snapshots.find(
+ (s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
+ );
+ if (snapshot && Number(snapshot.unitPrice) > 0) {
+ return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
+ }
+ const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
+ const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
+ if (line && Number(line.unitPrice) > 0) {
+ return { amount: Number(line.unitPrice), currency: breakdown!.currency };
+ }
+ return null;
+ }
+
+ private async feeAmount(
+ contract: Contract,
+ ): Promise<{ amount: number; currency: string }> {
+ const fee = await this.feeAmountOrNull(contract);
+ if (!fee) {
+ throw new UnprocessableEntityException(
+ `Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
+ );
+ }
+ return fee;
+ }
+
+ /**
+ * Whether the payment gate applies. Skipped for government/unlinked
+ * contracts (no company to bill — invoices require one, same rule the
+ * booking invoice applies) and for legacy customs contracts frozen before
+ * the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
+ * the pre-fee flow instead of dead-ending.
+ */
+ async gateApplies(contract: Contract): Promise {
+ if (!contract.customsClearingEnabled || !contract.companyId) return false;
+ if ((await this.feeAmountOrNull(contract)) !== null) return true;
+ this.logger.warn(
+ `Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
+ );
+ return false;
+ }
+
+ /** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
+ async issueForContract(contract: Contract): Promise {
+ const existing = await this.billing.findPayable(
+ Freight.InvoiceSource.Clearance,
+ contract.id,
+ CLEARANCE_CONTRACT_INVOICE_TYPE,
+ );
+ if (existing) return existing;
+
+ const { amount, currency } = await this.feeAmount(contract);
+ const invoice = await this.billing.generateInvoice({
+ source: Freight.InvoiceSource.Clearance,
+ sourceId: contract.id,
+ type: CLEARANCE_CONTRACT_INVOICE_TYPE,
+ companyId: contract.companyId!,
+ companyProfileId: contract.companyProfileId!,
+ currency,
+ lines: [
+ {
+ chargeType: 'CUSTOMS_CLEARANCE',
+ description: `Customs clearance service fee — contract ${contract.reference}`,
+ quantity: 1,
+ unitRate: amount,
+ amount,
+ currency,
+ },
+ ],
+ status: Freight.InvoiceStatus.Pending,
+ });
+ this.notifier.clearanceFeeDue(contract, amount, currency);
+ return invoice;
+ }
+
+ /** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
+ async issueForBooking(booking: Booking, contract: Contract): Promise {
+ const existing = await this.billing.findPayable(
+ Freight.InvoiceSource.Clearance,
+ booking.id,
+ CLEARANCE_BOOKING_INVOICE_TYPE,
+ );
+ if (existing) return existing;
+
+ const { amount, currency } = await this.feeAmount(contract);
+ const invoice = await this.billing.generateInvoice({
+ source: Freight.InvoiceSource.Clearance,
+ sourceId: booking.id,
+ type: CLEARANCE_BOOKING_INVOICE_TYPE,
+ companyId: booking.companyId ?? contract.companyId!,
+ companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
+ currency,
+ lines: [
+ {
+ chargeType: 'CUSTOMS_CLEARANCE',
+ description: `Customs clearance service fee — shipment ${booking.reference}`,
+ quantity: 1,
+ unitRate: amount,
+ amount,
+ currency,
+ },
+ ],
+ status: Freight.InvoiceStatus.Pending,
+ });
+ this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
+ return invoice;
+ }
+
+ /**
+ * Settlement branch point for `clearance`-source invoices: unlock the
+ * document-upload step the fee was gating. Idempotent — a replayed event on
+ * an already-advanced contract/booking is a no-op.
+ */
+ @OnEvent('clearance.invoice.paid')
+ async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise {
+ this.logger.log(
+ `clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
+ );
+ switch (payload.type) {
+ case CLEARANCE_CONTRACT_INVOICE_TYPE:
+ await this.advanceContract(payload.sourceId);
+ break;
+ case CLEARANCE_BOOKING_INVOICE_TYPE:
+ await this.advanceBooking(payload.sourceId);
+ break;
+ default:
+ this.logger.warn(
+ `Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
+ );
+ }
+ }
+
+ private async advanceContract(contractId: string): Promise {
+ const contract = await this.contractsRepository.findById(contractId);
+ if (!contract) {
+ this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
+ return;
+ }
+ if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
+
+ await this.contractsRepository.update(contractId, {
+ status: 'AWAITING_CLEARANCE_DOCUMENTS',
+ clearanceStatus: 'AWAITING_DOCUMENTS',
+ clearanceFeePaidAt: new Date(),
+ } as never);
+ const updated = await this.contractsRepository.findByIdWithRelations(contractId);
+ if (updated) this.notifier.clearanceFeePaid(updated);
+ }
+
+ private async advanceBooking(bookingId: string): Promise {
+ const booking = await this.bookingsRepository.findById(bookingId);
+ if (!booking) {
+ this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
+ return;
+ }
+ if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
+
+ await this.bookingsRepository.update(bookingId, {
+ status: 'AWAITING_DOCUMENTS',
+ clearanceFeePaidAt: new Date(),
+ } as never);
+ if (booking.contractId) {
+ const contract = await this.contractsRepository.findByIdWithRelations(
+ booking.contractId,
+ );
+ if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
index 7dc676398..4f3deb513 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
+ {} as never, // clearanceFeeService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
index c2e3a107b..f28468936 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts
@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
+ {} as never, // clearanceFeeService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index f055a0c15..6078dee0f 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
+import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
@@ -78,6 +79,7 @@ export class ContractBookingService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
+ private readonly clearanceFeeService: ClearanceFeeService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@@ -492,6 +494,11 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, opts.contractRouteId);
+ // Prepay gate: each shipment request owes its own flat clearance service
+ // fee before the document step opens (the paid event advances the booking
+ // to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
+ const feeGate = await this.clearanceFeeService.gateApplies(contract);
+
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
@@ -501,7 +508,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
- status: 'AWAITING_DOCUMENTS',
+ status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -540,6 +547,10 @@ export class ContractBookingService {
contract.tradeDirection,
);
+ if (feeGate) {
+ await this.clearanceFeeService.issueForBooking(booking, contract);
+ }
+
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
}
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
index 60eebf3da..d3bbaf098 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts
@@ -489,6 +489,11 @@ export class ContractClearanceService {
files: Express.Multer.File[],
): Promise {
const contract = await this.contractsService.findById(contractId);
+ if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
+ throw new ConflictException(
+ 'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
+ );
+ }
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
index 3834b55f0..575767a87 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts
@@ -158,6 +158,26 @@ export class ContractNotifierService {
});
}
+ /** Clearance service fee invoiced — customer must pay before document upload. */
+ clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
+ const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
+ const msg =
+ `A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
+ `Please pay from the portal to unlock the clearance document upload.`;
+ void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
+ this.inApp(c, 'Clearance fee due', msg);
+ }
+
+ /** Clearance service fee settled — document upload is now open. */
+ clearanceFeePaid(c: Contract, shipmentRef?: string): void {
+ const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
+ const msg =
+ `Your customs clearance service fee for ${scope} has been received. ` +
+ `You can now upload the clearance documents from the portal.`;
+ void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
+ this.inApp(c, 'Clearance fee paid', msg);
+ }
+
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
index 286cd9a01..1646d638c 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
containerSize?: string | null;
conditionalOn?: string | null;
cargoTypeCode?: string | null;
+ /**
+ * Customs clearance service fee — billed separately in advance (before the
+ * clearance document step), never part of shipment booking totals.
+ */
+ isClearance?: boolean;
}
/** The contract `pricing_breakdown` shape (doc §9.1). */
@@ -184,6 +189,31 @@ export class ContractPricingService {
}
}
+ // Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
+ // contract and billed via its own clearance invoice: after counter-sign for
+ // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
+ // A customs contract may not proceed without a configured live rate.
+ if (contract.customsClearingEnabled) {
+ const clearance = liveRates.find(
+ (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
+ );
+ if (!clearance || Number(clearance.rateValue) <= 0) {
+ throw new UnprocessableEntityException(
+ 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
+ );
+ }
+ lineItems.push({
+ code: 'CUSTOMS_CLEARANCE',
+ label:
+ contract.contractKind === 'GENERAL'
+ ? 'Customs clearance service fee (per shipment request, prepaid)'
+ : 'Customs clearance service fee (prepaid)',
+ unit: toContractUnit(clearance.rateUnit),
+ unitPrice: convert(Number(clearance.rateValue)),
+ isClearance: true,
+ });
+ }
+
return {
displayMode: 'UNIT_RATES',
currency,
@@ -229,6 +259,7 @@ export class ContractPricingService {
containerSize: line.containerSize ?? null,
isSurcharge: !!line.conditionalOn,
conditionalOn: line.conditionalOn ?? null,
+ isClearance: !!line.isClearance,
});
}
}
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 049db2a94..5fb935e74 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
@@ -24,6 +24,7 @@ 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 { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
@@ -89,6 +90,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
+ private readonly clearanceFeeService: ClearanceFeeService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -866,8 +868,17 @@ export class ContractTransitionService {
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
- updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
- updates.clearanceStatus = 'AWAITING_DOCUMENTS';
+ // Path B prepay gate: the customs clearance service fee is invoiced here
+ // and must settle before the document step opens (the paid event advances
+ // to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
+ if (await this.clearanceFeeService.gateApplies(contract)) {
+ await this.clearanceFeeService.issueForContract(contract);
+ updates.status = 'AWAITING_CLEARANCE_PAYMENT';
+ updates.clearanceStatus = 'AWAITING_PAYMENT';
+ } else {
+ updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
+ updates.clearanceStatus = 'AWAITING_DOCUMENTS';
+ }
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
index 96bdf22b1..5177b6a39 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts
@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
+import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
+ ClearanceFeeService,
ContractNotifierService,
ContractTransitionService,
ContractClearanceService,
diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts
index eb0a607cc..52fcd437f 100644
--- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts
+++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts
@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
/** is_hazardous | is_reefer when this is a conditional surcharge. */
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
conditionalOn?: string | null;
+
+ /**
+ * Customs clearance service fee line — billed up front via a clearance
+ * invoice, excluded from shipment booking totals.
+ */
+ @Column({ name: 'is_clearance', type: 'boolean', default: false })
+ isClearance!: boolean;
}
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 f29b9093b..a526d632f 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
@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
+ 'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
@@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
+ 'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
@@ -215,6 +217,10 @@ export class Contract extends BaseEntity {
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number;
+ /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
+ @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
+ clearanceFeePaidAt?: Date | null;
+
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record | null;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts
index 579f7da9d..5ecc006c1 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts
@@ -37,6 +37,8 @@ export function deriveRateType(input: {
return 'DEMURRAGE';
case 'PIL_EXTRA_FEE':
return 'PIL_EXTRA_FEE';
+ case 'CUSTOMS_CLEARANCE':
+ return 'CUSTOMS_CLEARANCE';
}
}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
index cef613412..b7ffdc485 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
@@ -30,6 +30,9 @@ export function allowedRateUnits(input: {
return ['PER_CONTAINER', 'PER_TON'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
+ case 'CUSTOMS_CLEARANCE':
+ // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
+ return ['FLAT'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
index 50f8b3b99..d66358cc9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
@@ -21,6 +21,7 @@ export const RATE_TYPES = [
'HAZARD_SURCHARGE',
'REEFER_SURCHARGE',
'PIL_EXTRA_FEE',
+ 'CUSTOMS_CLEARANCE',
] as const;
export type RateType = typeof RATE_TYPES[number];
@@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [
'CANCELLATION',
'DEMURRAGE',
'PIL_EXTRA_FEE',
+ // Customs clearance service fee — billed up front via a clearance invoice,
+ // never auto-applied to booking pricing (matchesTrigger returns false).
+ 'CUSTOMS_CLEARANCE',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts
new file mode 100644
index 000000000..c91bb30a1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts
@@ -0,0 +1,29 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
+
+/**
+ * Edit a built train's display identity: its name and its fixed import/export
+ * run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
+ * Omitted fields keep their current value; an empty trainName clears the name.
+ */
+export class UpdateTrainDetailsDto {
+ @ApiPropertyOptional({ description: 'Display name; empty string clears it' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(100)
+ trainName?: string;
+
+ @ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(20)
+ importTrainNumber?: string;
+
+ @ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(20)
+ exportTrainNumber?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
index 5b26c696a..493e564e8 100644
--- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
+++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
trainNumber?: string;
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
- trainName?: string;
+ trainName?: string | null;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string;
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
index eec68fcfc..9b7454f36 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
+import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@@ -59,6 +60,18 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
+ @Patch(':id/details')
+ @FleetManage()
+ @ApiOperation({
+ summary: "Edit the train's name and fixed import/export run numbers",
+ })
+ updateDetails(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: UpdateTrainDetailsDto,
+ ) {
+ return this.trainBuilderService.updateDetails(id, dto);
+ }
+
@Patch(':id/yard')
@FleetManage()
@ApiOperation({
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 3b5ad16cd..50fa77afa 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
@@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
+import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
@@ -331,6 +332,53 @@ export class TrainBuilderService {
return this.getComposition(id);
}
+ /**
+ * Edit a built train's display identity: name and fixed import/export run
+ * numbers. Mirrors the build-time number rules — the pair may not collide
+ * with any other train's pair or legacy number (friendly 409 ahead of the
+ * partial unique indexes). Blocked while the train is out on a dispatched
+ * run, like every other composition edit.
+ */
+ async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
+ await this.dataSource.transaction(async (manager) => {
+ const train = await this.getEditableTrain(manager, id);
+
+ const patch: Partial = {};
+ if (dto.trainName !== undefined) {
+ patch.trainName = dto.trainName.trim() || null;
+ }
+ const importTrainNumber = dto.importTrainNumber?.trim();
+ const exportTrainNumber = dto.exportTrainNumber?.trim();
+ if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
+ if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
+
+ if (importTrainNumber || exportTrainNumber) {
+ const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
+ const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
+ const numberClash: { code: string }[] = await manager.query(
+ `SELECT code FROM freight.trains
+ WHERE deleted_at IS NULL
+ AND id != $3
+ AND (import_train_number IN ($1, $2)
+ OR export_train_number IN ($1, $2)
+ OR train_number IN ($1, $2))
+ LIMIT 1`,
+ [nextImport, nextExport, train.id],
+ );
+ if (numberClash.length) {
+ throw new ConflictException(
+ `Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
+ );
+ }
+ }
+
+ if (Object.keys(patch).length) {
+ await manager.getRepository(Train).update(train.id, patch);
+ }
+ });
+ return this.getComposition(id);
+ }
+
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx
new file mode 100644
index 000000000..0a2371138
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx
@@ -0,0 +1,123 @@
+import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
+import { useMutation } from "@tanstack/react-query";
+import { Pencil } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { useToast } from "@/hooks/use-toast";
+import { api } from "@/services/api";
+import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
+
+export interface EditTrainDetailsModalProps {
+ /** Train being edited; null closes the modal. */
+ train: BuiltTrainSummary | null;
+ onClose: () => void;
+}
+
+/**
+ * Edit a built train's display identity from the list: its name and its fixed
+ * import/export run numbers. Composition (yard, locomotives, wagons) is edited
+ * on the detail page. Number collisions come back as a 409 with the owning
+ * train's code and surface verbatim.
+ */
+const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
+ const { toast } = useToast();
+ const [name, setName] = useState("");
+ const [importNo, setImportNo] = useState("");
+ const [exportNo, setExportNo] = useState("");
+
+ useEffect(() => {
+ if (train) {
+ setName(train.trainName ?? "");
+ setImportNo(train.importTrainNumber ?? "");
+ setExportNo(train.exportTrainNumber ?? "");
+ }
+ }, [train]);
+
+ const update = useMutation(api.trainBuilder.updateDetails.mutationOptions());
+
+ const handleSave = async () => {
+ if (!train) return;
+ try {
+ await update.mutateAsync({
+ id: train.id,
+ payload: {
+ trainName: name.trim(),
+ // Numbers cannot be cleared — only replaced; empty inputs keep the
+ // current value (legacy trains may have none yet).
+ ...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}),
+ ...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}),
+ },
+ });
+ toast({ title: `Train ${train.code} updated` });
+ onClose();
+ } catch (err) {
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "Update failed";
+ toast({
+ title: "Could not update train",
+ description: String(message),
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+
+ Edit train {train?.code ?? ""}
+
+ }
+ centered
+ size="md"
+ radius="lg"
+ >
+
+ setName(e.currentTarget.value)}
+ maxLength={100}
+ radius="md"
+ />
+
+ setImportNo(e.currentTarget.value)}
+ maxLength={20}
+ radius="md"
+ />
+ setExportNo(e.currentTarget.value)}
+ maxLength={20}
+ radius="md"
+ />
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+};
+
+export default EditTrainDetailsModal;
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
index f87f96433..e5961240f 100644
--- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
@@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [
{
key: "clearance",
label: "Clearance",
- statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
+ statuses: [
+ "AWAITING_CLEARANCE_PAYMENT",
+ "AWAITING_DOCUMENTS",
+ "DOCUMENTS_UNDER_REVIEW",
+ "CLEARANCE_READY",
+ ],
},
{
key: "payment",
diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts
index 2277d702b..cf1f04c90 100644
--- a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts
+++ b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts
@@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record = {
label: "Active",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
+ AWAITING_CLEARANCE_PAYMENT: {
+ label: "Clearance Fee Due",
+ color: "bg-orange-50 text-orange-700 border-orange-200",
+ },
AWAITING_CLEARANCE_DOCUMENTS: {
label: "Awaiting Documents",
color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record = {
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
CONTRACT_ACTIVE: "edr-green",
+ AWAITING_CLEARANCE_PAYMENT: "orange",
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green",
@@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record = {
color: "text-[color:var(--freight-brand)]",
stage: 3,
},
+ AWAITING_CLEARANCE_PAYMENT: {
+ title: "Clearance Fee Due",
+ description:
+ "Customer must pay the prepaid clearance service fee before uploading documents.",
+ color: "text-orange-600",
+ stage: 3,
+ },
AWAITING_CLEARANCE_DOCUMENTS: {
title: "Awaiting Documents",
description: "Customer is uploading pre-booking clearance documents.",
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index 4423b4f93..9e248eee2 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -141,6 +141,7 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
+ { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" },
];
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -162,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
+ case "CUSTOMS_CLEARANCE":
+ // Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
+ return ["FLAT"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
index de6b96aa5..0d6b7a892 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
+ ActionIcon,
Badge,
Box,
Button,
@@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Hammer,
+ Pencil,
Ruler,
Search,
Train as TrainIcon,
@@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
+import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
@@ -52,6 +55,7 @@ export default function TrainBuilderListPage() {
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false);
+ const [editTarget, setEditTarget] = useState(null);
const resetPage = useCallback(() => {
setPagination((prev) =>
@@ -239,6 +243,26 @@ export default function TrainBuilderListPage() {
),
},
+ {
+ id: "actions",
+ header: "",
+ meta: { headerClassName, cellClassName },
+ cell: ({ row }) => (
+ {
+ // Row click navigates to the detail page — keep the edit local.
+ e.stopPropagation();
+ setEditTarget(row.original);
+ }}
+ >
+
+
+ ),
+ },
];
}, []);
@@ -363,6 +387,8 @@ export default function TrainBuilderListPage() {
onClose={() => setBuildOpen(false)}
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
/>
+
+ setEditTarget(null)} />
);
}
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index 88d922829..7a2a478c1 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -191,6 +191,7 @@ import {
type BuiltTrainListResponse,
type ScheduleConsist,
type TrainComposition,
+ type UpdateTrainDetailsPayload,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
@@ -1842,6 +1843,18 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
+ updateDetails: endpoint<
+ { id: string; payload: UpdateTrainDetailsPayload },
+ TrainComposition
+ >(
+ "train-builder",
+ "updateDetails",
+ ({ id, payload }) =>
+ trainBuilderService.updateDetails(id, payload).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index f78792b81..62ae9f7a3 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -140,6 +140,14 @@ export interface BuildTrainPayload {
notes?: string;
}
+/** Edit a built train's display identity; omitted fields keep their value. */
+export interface UpdateTrainDetailsPayload {
+ /** Empty string clears the name. */
+ trainName?: string;
+ importTrainNumber?: string;
+ exportTrainNumber?: string;
+}
+
/** Built train annotated for the schedule-creation picker. */
export interface AvailableTrain {
id: string;
@@ -241,6 +249,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }),
+ /** Edit the train's name and fixed import/export run numbers. */
+ updateDetails: (id: string, payload: UpdateTrainDetailsPayload) =>
+ apiClient.patch(`${BASE}/${id}/details`, payload),
/** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) =>
apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }),
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
index c31118cc7..ec31e11fa 100644
--- a/apps/edr-freight-web/backoffice/src/types/booking.ts
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
// Post counter-sign document-clearance gate.
+ "AWAITING_CLEARANCE_PAYMENT",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
index 86c37585c..019f83143 100644
--- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
+++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
@@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
+import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction";
@@ -69,6 +70,17 @@ export function ContractCustomerAction({
);
}
+ if (action.type === "pay-clearance") {
+ return (
+
+ );
+ }
+
if (action.type === "initiate") {
return (
invoicesService.listForSource("booking", payItem!.targetId),
+ queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
+ queryFn: () =>
+ invoicesService.listForSource(payItemSource, payItem!.targetId),
enabled: payItem !== null,
});
const payableInvoiceId = payItemInvoices.find((inv) =>
@@ -182,6 +188,7 @@ export function ActionNeededSection({
navigate(`/contracts/${item.targetId}`);
break;
case "pay":
+ case "clearance-fee":
setPayItem(item);
break;
case "sign":
@@ -277,7 +284,9 @@ export function ActionNeededSection({
>
{item.kind === "pay"
? "Pay now"
- : item.kind === "duty"
+ : item.kind === "clearance-fee"
+ ? "Pay clearance fee"
+ : item.kind === "duty"
? "Pay duty & upload slip"
: item.kind === "sign"
? "Sign"
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
index 7be5a0aed..b67b11315 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts
@@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
+ AWAITING_CLEARANCE_PAYMENT: {
+ stage: 3,
+ icon: Wallet,
+ iconColor: "edr-amber-text",
+ tile: "edr-amber-soft",
+ hint: "Clearance service fee due · pay to unlock document upload",
+ step: "edr-accent",
+ badgeLabel: "Clearance fee due",
+ badgeBg: "edr-amber-soft",
+ badgeText: "edr-amber-text",
+ badgeDot: "edr-accent",
+ action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
+ },
AWAITING_DOCUMENTS: {
stage: 3,
icon: FileUp,
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
index 04739b55d..244a38739 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx
@@ -1,4 +1,4 @@
-import { Group, Tabs } from "@mantine/core";
+import { Group, Paper, Tabs, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
@@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
+import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab";
@@ -139,6 +140,8 @@ export function ReadonlyBookingView({
const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
+ // Prepaid clearance service fee gate — document upload stays locked until paid.
+ const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
const isClearance = [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
@@ -237,6 +240,28 @@ export function ReadonlyBookingView({
+ {isAwaitingClearanceFee && (
+
+
+
+
+ Customs clearance service fee due
+
+
+ Pay the clearance service fee to unlock the clearance
+ document upload. Global Logistics starts working on your
+ shipment once the fee is settled.
+
+
+
+
+
+ )}
+
{isClearance &&
}
= {
+ PAY_CLEARANCE: CreditCard,
UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight,
@@ -56,6 +59,19 @@ export function BookingActionButton({
if (!isChangesRequested && !action) return null;
+ // The prepaid clearance service fee has its own payment flow (method modal +
+ // provider redirect) — delegate to the self-contained pay button.
+ if (action?.kind === "PAY_CLEARANCE") {
+ return (
+
+ );
+ }
+
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit";
// BOOK navigates to the booking form (cargo + day + window check) — the
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts
index 1ebb047a6..2bdd976a9 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts
@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
* to operation.
*/
export type BookingActionKind =
+ | "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
@@ -23,6 +24,11 @@ export interface BookingNextAction {
}
const ACTION_BY_STATUS: Record = {
+ AWAITING_CLEARANCE_PAYMENT: {
+ kind: "PAY_CLEARANCE",
+ label: "Pay clearance fee",
+ title: "Pay the clearance service fee",
+ },
AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS",
label: "Upload documents",
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx
new file mode 100644
index 000000000..02cf37937
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx
@@ -0,0 +1,133 @@
+import { Button, type ButtonProps } from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { CreditCard } from "lucide-react";
+import { useState } from "react";
+
+import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
+import { isPayable } from "@/pages/billing/invoice-ui";
+import { api } from "@/services/api";
+import { invoicesService } from "@/services/invoices.service";
+import {
+ paymentsService,
+ type PaymentMethod,
+} from "@/services/payments.service";
+import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
+
+/**
+ * Payment flow for the prepaid customs clearance service fee. The fee is its
+ * own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
+ * contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
+ * shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
+ * unlocks the clearance document upload; same modal + provider redirect as
+ * booking payment.
+ */
+export function useClearanceFeePayment(sourceId: string) {
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const { data: invoices = [] } = useQuery({
+ queryKey: ["clearance-invoices", sourceId],
+ queryFn: () => invoicesService.listForSource("clearance", sourceId),
+ enabled: Boolean(sourceId),
+ });
+ const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
+
+ const mutation = useMutation({
+ mutationFn: (method: PaymentMethod) => {
+ if (!payableInvoice) {
+ throw new Error(
+ "No payable clearance-fee invoice found yet. Please refresh or contact support.",
+ );
+ }
+ return api.invoices.pay.call({
+ id: payableInvoice.id,
+ payload: { method, platform: "web" },
+ });
+ },
+ onSuccess: (data, method) => {
+ const redirectUrl =
+ data?.clientAction?.type === "REDIRECT" && data.clientAction.url
+ ? data.clientAction.url
+ : paymentsService.checkoutUrlForInvoice({
+ invoiceId: payableInvoice!.id,
+ method,
+ });
+ window.location.href = redirectUrl;
+ },
+ });
+
+ const close = () => {
+ if (!mutation.isPending) {
+ setModalOpen(false);
+ mutation.reset();
+ }
+ };
+
+ return {
+ invoice: payableInvoice,
+ modalOpen,
+ open: () => setModalOpen(true),
+ close,
+ processing: mutation.isPending,
+ error: mutation.isError
+ ? mutation.error instanceof Error
+ ? mutation.error.message
+ : "Could not start payment. Please try again."
+ : null,
+ confirm: (method: PaymentMethod) => mutation.mutate(method),
+ };
+}
+
+interface PayClearanceFeeButtonProps {
+ /** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
+ sourceId: string;
+ /** Fallback currency while the invoice is loading. */
+ currency?: string;
+ label?: string;
+ size?: ButtonProps["size"];
+ fullWidth?: boolean;
+}
+
+/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
+export function PayClearanceFeeButton({
+ sourceId,
+ currency,
+ label = "Pay clearance fee",
+ size = "xs",
+ fullWidth,
+}: PayClearanceFeeButtonProps) {
+ const pay = useClearanceFeePayment(sourceId);
+
+ return (
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ pay.open();
+ }}
+ >
+ {label}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
index 64c64ce26..1b5a29e2a 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx
@@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
+import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -396,6 +397,9 @@ export default function ContractDetailPage() {
// clearance is finalized.
const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
+ // Prepaid clearance service fee gate (Path B) — the document step stays
+ // locked until the fee invoice settles.
+ const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
return (
@@ -531,6 +535,13 @@ export default function ContractDetailPage() {
Global Logistics is creating your booking
)}
+ {awaitingClearanceFee && (
+
+ )}
{canUploadClearance && (
)}
+ {item.isClearance && (
+
+ Paid in advance, before clearance — excluded from
+ shipment invoices
+
+ )}
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
index a30255ddf..81c42a3a0 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
@@ -1005,6 +1005,12 @@ export default function NewContractPage({
{item.containerSize}
)}
+ {item.isClearance && (
+
+ Paid in advance, before clearance — not part of your
+ shipment booking invoice
+
+ )}
{
// Clearance-first flow: the request auto-initiates a booking instance —
- // send the customer straight to it to upload clearance documents.
+ // send the customer straight to it. The clearance service fee is due
+ // first; document upload unlocks once it settles.
if (request.createdBookingId) {
toast.success(
- "Shipment initiated — upload your clearance documents to start the review.",
+ "Shipment initiated — pay the clearance service fee to unlock the document upload.",
);
navigate(`/bookings/${request.createdBookingId}`);
} else {
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
index d8d6553ba..e5a41121c 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx
@@ -125,6 +125,10 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
// ── Path B pre-booking clearance (contract-level) ──
+ AWAITING_CLEARANCE_PAYMENT: {
+ label: "Clearance Fee Due",
+ ...TONE.warning,
+ },
AWAITING_CLEARANCE_DOCUMENTS: {
label: "Upload Clearance Docs",
...TONE.warning,
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index a0c51645f..ed4d6f0f4 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -42,6 +42,7 @@ export const CONTRACT_STATUSES = [
"FULLY_EXECUTED", // ONE_TIME
"CONTRACT_ACTIVE", // GENERAL
// customs clearance execution (Path B, pre-booking)
+ "AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
@@ -67,6 +68,7 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
*/
export const CONTRACT_CLEARANCE_STATUSES = [
"NOT_APPLICABLE",
+ "AWAITING_PAYMENT", // Path B — clearance service fee must be paid first
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
@@ -102,6 +104,11 @@ export interface ContractUnitRateLineItem {
/** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */
conditionalOn?: string | null;
cargoTypeCode?: string | null;
+ /**
+ * True for the customs clearance service fee — billed separately in advance
+ * (before document upload), never part of shipment booking totals.
+ */
+ isClearance?: boolean;
}
/** Contract `pricing_breakdown` shape — unit rates, no totals. */
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 9ed8b1a39..dc973d9c1 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -101,6 +101,8 @@ export enum BookingStatus {
PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED",
// Post counter-sign document-clearance gate (GL workflow).
+ /** Clearance service fee invoiced; docs + GL work locked until paid. */
+ AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT",
AwaitingDocuments = "AWAITING_DOCUMENTS",
DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW",
ClearanceReady = "CLEARANCE_READY",
@@ -173,7 +175,9 @@ export enum InvoiceSource {
Warehouse = "warehouse",
Demurrage = "demurrage",
FirstMile = "firstmile",
- LastMile = "lastmile"
+ LastMile = "lastmile",
+ /** Customs clearance service fee, prepaid before clearance work begins. */
+ Clearance = "clearance"
}
export enum SchedulingStatus {
From 234a74e812b85da15f3bd80d2511af9cfe5758f2 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Wed, 15 Jul 2026 22:48:04 +0000
Subject: [PATCH 58/67] implement empty-container return service: add return
quantity handling, update related DTOs, services, and UI components
---
...270000000000-AddContainerReturnQuantity.ts | 27 ++++++++
.../bookings/booking-pricing.service.ts | 5 ++
.../entities/booking-container.entity.ts | 4 ++
.../contracts/contract-booking.service.ts | 54 ++++++++++++++-
.../contracts/contract-pricing.service.ts | 19 ++++++
.../dto/create-booking-under-contract.dto.ts | 12 ++++
.../rule-engine/entities/rate-unit.util.ts | 3 +
.../rule-engine/entities/rate.entity.ts | 4 ++
.../rule-engine/rule-engine.service.ts | 9 +++
.../modules/trains/train-builder.service.ts | 3 +-
.../src/seed/pricing-data.seeder.ts | 3 +
.../contracts/GlCreateBookingForm.tsx | 67 +++++++++++++++++--
.../src/pages/ruleEngine/config/resources.ts | 4 ++
.../src/pages/contracts/NewContractPage.tsx | 13 +++-
.../src/pages/contracts/NewShipmentPage.tsx | 54 +++++++++++++--
.../contracts/new-contract-form/schema.ts | 4 +-
.../new-contract-form/step1-contract-type.tsx | 7 +-
.../new-contract-form/step3-cargo-scope.tsx | 24 ++++++-
.../contracts/new-shipment-form/schema.ts | 23 +++++++
packages/types/src/freight/contracts.ts | 5 ++
20 files changed, 324 insertions(+), 20 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts
diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts
new file mode 100644
index 000000000..4a37b5e98
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts
@@ -0,0 +1,27 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds freight.booking_container.return_quantity — how many units of a
+ * container line ship with the empty-container-return service (≤ quantity).
+ * Mirrors hazardous_quantity / reefer_quantity: captured per line at booking
+ * creation when the contract enables WITH_RETURN (container freight only) and
+ * drives the booking-level equipment_return flag that fires the WITH_RETURN
+ * pricing surcharge.
+ */
+export class AddContainerReturnQuantity2270000000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.booking_container
+ ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.booking_container
+ DROP COLUMN IF EXISTS return_quantity;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index 00cf55e17..58351c080 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -328,6 +328,11 @@ export class BookingPricingService {
// reefer quantity) applies the REEFER surcharge even for non-reefer
// container types. ORed with per-container reefer in the engine.
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
+ // Empty-container return service (container freight only) — bills the
+ // WITH_RETURN surcharge per container, like hazard/reefer.
+ withReturn:
+ booking.freightType === 'CONTAINER' &&
+ booking.equipmentReturn === 'WITH_RETURN',
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts
index 182ff153d..24ec5db7c 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts
@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
reeferQuantity!: number;
+ /** How many units of this line ship with empty-container return (≤ quantity). */
+ @Column({ name: 'return_quantity', type: 'smallint', default: 0 })
+ returnQuantity!: number;
+
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
index 6078dee0f..a0c2b42a4 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
@@ -263,7 +263,7 @@ export class ContractBookingService {
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
- equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
+ equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
@@ -665,7 +665,7 @@ export class ContractBookingService {
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
- ...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
+ equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
@@ -1416,6 +1416,47 @@ export class ContractBookingService {
);
}
+ /**
+ * Resolve the booking's equipment return from the per-line return quantities
+ * (container freight). The CONTRACT gates the service — like hazardous:
+ * - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
+ * line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
+ * - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
+ * booking-level override (dto.equipmentReturn ?? contract default) applies.
+ * Bulk freight keeps the legacy behaviour untouched.
+ */
+ private resolveShipmentEquipmentReturn(
+ contract: Contract,
+ dto: CreateBookingUnderContractDto,
+ ): string {
+ const legacy =
+ dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
+ if (contract.freightType !== 'CONTAINER') return legacy;
+
+ const lines = dto.containers ?? [];
+ for (const line of lines) {
+ const qty = Number(line.returnQuantity ?? 0);
+ if (qty === 0) continue;
+ if (contract.equipmentReturn !== 'WITH_RETURN') {
+ throw new BadRequestException(
+ 'This contract was not created with the empty-container return ' +
+ 'service — return quantities are not allowed on its bookings.',
+ );
+ }
+ if (qty > line.quantity) {
+ throw new BadRequestException(
+ `Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
+ );
+ }
+ }
+
+ if (contract.equipmentReturn === 'WITH_RETURN') {
+ const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
+ return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
+ }
+ return legacy;
+ }
+
/**
* Map each contract-scope container size to a concrete container type and
* persist the booking_container line + its per-unit container numbers. Weight
@@ -1466,6 +1507,10 @@ export class ContractBookingService {
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
+ returnQuantity:
+ contract.equipmentReturn === 'WITH_RETURN'
+ ? (line.returnQuantity ?? 0)
+ : 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
@@ -1586,6 +1631,7 @@ export class ContractBookingService {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
+ equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
@@ -1599,6 +1645,10 @@ export class ContractBookingService {
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
+ returnQuantity:
+ contract.equipmentReturn === 'WITH_RETURN'
+ ? (line.returnQuantity ?? 0)
+ : 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
index 1646d638c..235e1051f 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts
@@ -188,6 +188,25 @@ export class ContractPricingService {
});
}
}
+ // Empty-container return service — container contracts only, toggled on the
+ // contract like hazard/reefer. Billed at booking per WITH_RETURN container.
+ if (
+ contract.freightType === 'CONTAINER' &&
+ contract.equipmentReturn === 'WITH_RETURN'
+ ) {
+ const withReturn = liveRates.find(
+ (r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
+ );
+ if (withReturn && Number(withReturn.rateValue) > 0) {
+ lineItems.push({
+ code: 'RETURN_SURCHARGE',
+ label: 'Empty container return',
+ unit: toContractUnit(withReturn.rateUnit),
+ unitPrice: convert(Number(withReturn.rateValue)),
+ conditionalOn: 'with_return',
+ });
+ }
+ }
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
// contract and billed via its own clearance invoice: after counter-sign for
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
index 870817365..256b11b63 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts
@@ -77,6 +77,18 @@ export class CreateBookingContainerLineDto {
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
+ @ApiPropertyOptional({
+ minimum: 0,
+ description:
+ 'How many units of this line ship with empty-container return (≤ quantity). ' +
+ 'Only allowed when the contract was created WITH_RETURN (container freight).',
+ })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ @Transform(({ value }) => Number(value))
+ returnQuantity?: number;
+
@ApiProperty({ type: [CreateContainerUnitDto] })
@IsArray()
@ValidateNested({ each: true })
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
index b7ffdc485..9bbd7728d 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts
@@ -28,6 +28,9 @@ export function allowedRateUnits(input: {
return ['PER_CONTAINER', 'PER_TON'];
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
+ case 'WITH_RETURN':
+ // Container-only empty-return service — bills per returned container.
+ return ['PER_CONTAINER', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE':
diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
index d66358cc9..83c225ea1 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
@@ -20,6 +20,7 @@ export const RATE_TYPES = [
'OVERWEIGHT_PER_TON',
'HAZARD_SURCHARGE',
'REEFER_SURCHARGE',
+ 'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
] as const;
@@ -71,6 +72,9 @@ export const RATE_TRIGGERS = [
'HAZARDOUS',
'OVERWEIGHT',
'REEFER',
+ // Empty-container return service (container freight only) — fires when the
+ // booking ships WITH_RETURN, billed like hazard/reefer (usually PER_CONTAINER).
+ 'WITH_RETURN',
'SHIPPING_LINE',
'CONSOLIDATION',
'CANCELLATION',
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
index e451098fc..bc1a8095d 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
@@ -53,6 +53,11 @@ export interface BookingEvaluationInput {
isHazardous: boolean;
/** Booking-level reefer flag; ORed with per-container reefer. */
isReefer?: boolean;
+ /**
+ * Booking ships with empty-container return (equipment_return = WITH_RETURN,
+ * container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
+ */
+ withReturn?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
@@ -228,6 +233,7 @@ export class RuleEngineService {
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
+ withReturn: input.withReturn ?? false,
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
@@ -456,6 +462,7 @@ export class RuleEngineService {
state: {
isHazardous: boolean;
hasReefer: boolean;
+ withReturn: boolean;
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
@@ -469,6 +476,8 @@ export class RuleEngineService {
return truthy(state.isHazardous);
case 'REEFER':
return truthy(state.hasReefer);
+ case 'WITH_RETURN':
+ return truthy(state.withReturn);
case 'OVERWEIGHT':
return truthy(state.hasOverweight);
case 'SHIPPING_LINE':
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 50fa77afa..10b400993 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
@@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
+import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -343,7 +344,7 @@ export class TrainBuilderService {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
- const patch: Partial = {};
+ const patch: QueryDeepPartialEntity = {};
if (dto.trainName !== undefined) {
patch.trainName = dto.trainName.trim() || null;
}
diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
index 6b6b5f198..872e909bb 100644
--- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
+++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
@@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise {
// Small test values (< 20) so the surcharge stays a minor add for now.
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
+ // Empty-container return service — container contracts opted in at
+ // creation; bills per container on WITH_RETURN bookings.
+ { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index 0cd00d63b..9e7fa15ca 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -97,6 +97,7 @@ interface LineErrors {
quantity?: string;
hazardousQuantity?: string;
reeferQuantity?: string;
+ returnQuantity?: string;
units?: string;
}
@@ -135,6 +136,8 @@ interface ContainerLineDraft {
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
+ /** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
+ returnQuantity: string;
units: UnitDraft[];
}
@@ -155,6 +158,7 @@ function emptyLine(size: string): ContainerLineDraft {
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
+ returnQuantity: "0",
units: [emptyUnit()],
};
}
@@ -272,6 +276,14 @@ export default function GlCreateBookingForm() {
}, [contract]);
const isContainer = contract?.freightType === "CONTAINER";
+ // The contract gates the empty-container return service — like hazardous.
+ // WITH_RETURN contracts capture a per-line return quantity instead of the
+ // legacy booking-level toggle; other contracts cannot switch it on.
+ const contractWithReturn =
+ isContainer && contract?.equipmentReturn === "WITH_RETURN";
+ // Legacy contracts (no equipment return chosen at creation) keep the old
+ // booking-level toggle.
+ const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -354,6 +366,7 @@ export default function GlCreateBookingForm() {
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: String(c.hazardousQuantity ?? 0),
reeferQuantity: String(c.reeferQuantity ?? 0),
+ returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
})),
);
@@ -390,6 +403,7 @@ export default function GlCreateBookingForm() {
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
+ returnQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
@@ -542,6 +556,8 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
+ returnQuantity:
+ prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -614,9 +630,17 @@ export default function GlCreateBookingForm() {
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
+ if (contractWithReturn) {
+ const w = Number(line.returnQuantity || 0);
+ if (Number.isNaN(w) || w < 0) {
+ errs.returnQuantity = "Enter a valid return quantity.";
+ } else if (w > qty) {
+ errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`;
+ }
+ }
return errs;
});
- }, [isContainer, contract, containerLines]);
+ }, [isContainer, contract, containerLines, contractWithReturn]);
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
@@ -653,7 +677,11 @@ export default function GlCreateBookingForm() {
const cargoValid = isContainer
? lineErrors.every(
(e) =>
- !e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
+ !e.quantity &&
+ !e.units &&
+ !e.hazardousQuantity &&
+ !e.reeferQuantity &&
+ !e.returnQuantity,
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
@@ -675,8 +703,10 @@ export default function GlCreateBookingForm() {
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
- // Equipment return is a container concern — bulk keeps the contract default.
- ...(isContainer
+ // Equipment return: WITH_RETURN contracts derive it server-side from the
+ // per-line return quantities; only legacy contracts (no value chosen at
+ // creation) still send the booking-level toggle. Bulk keeps the default.
+ ...(legacyReturnToggle
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
@@ -689,6 +719,9 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
+ ...(contractWithReturn
+ ? { returnQuantity: Number(l.returnQuantity || 0) }
+ : {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -1168,6 +1201,28 @@ export default function GlCreateBookingForm() {
styles={fieldStyles}
/>
)}
+ {contractWithReturn && (
+
+ patchLine(lineIdx, {
+ returnQuantity: e.currentTarget.value,
+ })
+ }
+ radius={10}
+ styles={fieldStyles}
+ />
+ )}
Per-container details
@@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() {
)}
- {isContainer ? (
+ {/* Legacy contracts only — WITH_RETURN contracts capture per-line
+ return quantities above, WITHOUT_RETURN contracts locked it off. */}
+ {legacyReturnToggle ? (
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index 9e248eee2..c7c76b91b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -136,6 +136,7 @@ const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{ label: "Reefer cargo", value: "REEFER" },
+ { label: "Empty container return", value: "WITH_RETURN" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Cancellation", value: "CANCELLATION" },
@@ -161,6 +162,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
case "HAZARDOUS":
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
+ case "WITH_RETURN":
+ // Container-only service — bills per returned container.
+ return ["PER_CONTAINER", "FLAT"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
index 81c42a3a0..67a1a9071 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx
@@ -592,8 +592,17 @@ export default function NewContractPage({
: Freight.ContractFreightType.Bulk,
serviceTypeId: data.serviceTypeId,
paymentCurrency: data.paymentCurrency,
- // Equipment return is decided at booking time, not on the contract. Omit
- // it here so we don't send a value the contract API rejects.
+ // Empty-container return is a contract-level opt-in (container freight
+ // only) — like hazardous. Per-booking return quantities are still set at
+ // booking time, but only on contracts created WITH_RETURN.
+ ...(isContainer
+ ? {
+ equipmentReturn:
+ data.equipmentReturn === "with_return"
+ ? "WITH_RETURN"
+ : "WITHOUT_RETURN",
+ }
+ : {}),
isHazardous: data.isHazardous,
// Reefer is a contract-level flag for both container and bulk.
isReefer: data.isRefrigerated,
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
index 8233b83d6..08fe35537 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
@@ -258,6 +258,9 @@ function NewShipmentBookingForm({
isContainer: contract.freightType === "CONTAINER",
isHazardous: contract.isHazardous ?? false,
isReefer: contract.isReefer ?? false,
+ withReturnService:
+ contract.freightType === "CONTAINER" &&
+ contract.equipmentReturn === "WITH_RETURN",
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
@@ -296,6 +299,12 @@ function NewShipmentBookingForm({
values: ShipmentFormValues,
): Freight.CreateBookingUnderContractDto {
const isContainer = contract.freightType === "CONTAINER";
+ // WITH_RETURN contracts carry a per-line return quantity and the server
+ // derives the booking's equipment return from it; only legacy contracts
+ // (no equipment return chosen at creation) still send the toggle.
+ const withReturnService =
+ isContainer && contract.equipmentReturn === "WITH_RETURN";
+ const legacyReturnToggle = isContainer && !contract.equipmentReturn;
return {
...(values.contractRouteId
? { contractRouteId: values.contractRouteId }
@@ -304,10 +313,16 @@ function NewShipmentBookingForm({
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
: {}),
+ ...(legacyReturnToggle
+ ? {
+ equipmentReturn: values.withReturn
+ ? "WITH_RETURN"
+ : "WITHOUT_RETURN",
+ }
+ : {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
? {
- equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
containers: values.containers
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
@@ -315,6 +330,9 @@ function NewShipmentBookingForm({
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
+ ...(withReturnService
+ ? { returnQuantity: Number(l.returnQuantity || 0) }
+ : {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
@@ -443,9 +461,10 @@ function NewShipmentBookingForm({
- {contract.freightType === "CONTAINER" && (
-
- )}
+ {/* Legacy contracts only — WITH_RETURN contracts capture per-line
+ return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
+ {contract.freightType === "CONTAINER" &&
+ !contract.equipmentReturn && }
@@ -1075,6 +1094,7 @@ function CargoStep({
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
+ returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
})),
{ shouldValidate: false },
@@ -1117,6 +1137,7 @@ function CargoStep({
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
+ returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
}
);
@@ -1126,6 +1147,8 @@ function CargoStep({
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
+ returnQuantity:
+ current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -1234,6 +1257,7 @@ function CargoStep({
size={line.containerSize}
isHazardous={contract.isHazardous}
isReefer={contract.isReefer}
+ withReturnService={contract.equipmentReturn === "WITH_RETURN"}
/>
))}
{sizes.length === 0 && (
@@ -1433,12 +1457,15 @@ function ContainerLineEditor({
size,
isHazardous,
isReefer,
+ withReturnService,
}: {
form: ShipmentForm;
index: number;
size: "20ft" | "40ft";
isHazardous: boolean;
isReefer: boolean;
+ /** Contract opted into empty-container return — capture the per-line quantity. */
+ withReturnService?: boolean;
}) {
const line = form.watch(`containers.${index}`);
const quantity = Number(line?.quantity || 0);
@@ -1519,6 +1546,25 @@ function ContainerLineEditor({
)}
/>
)}
+ {withReturnService && (
+ (
+
+ )}
+ />
+ )}
Per-container details
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts
index 4a57c120f..1246f8050 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts
@@ -162,7 +162,7 @@ export const contractFormSchema = z
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
- .default("with_return"),
+ .default("without_return"),
customsClearingEnabled: z.boolean().default(false),
customsClearingAgent: z.string().default(""),
@@ -275,7 +275,7 @@ export const initialContractFormValues: DeepPartial = {
paymentCurrency: undefined,
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
- equipmentReturn: "with_return",
+ equipmentReturn: "without_return",
customsClearingEnabled: false,
customsClearingAgent: "",
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx
index 19a441045..2bacf8074 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx
@@ -126,11 +126,12 @@ export function Step1ContractType({
});
// ── Equipment return / customs ──
+ // Stored uppercase on the contract (WITH_RETURN / WITHOUT_RETURN).
form.setValue(
"equipmentReturn",
- contract.equipmentReturn === "without_return"
- ? "without_return"
- : "with_return",
+ (contract.equipmentReturn ?? "").toUpperCase() === "WITH_RETURN"
+ ? "with_return"
+ : "without_return",
);
form.setValue(
"customsClearingEnabled",
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
index 26b455258..3de8baad1 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
-import { Flame, Snowflake } from "lucide-react";
+import { Flame, RotateCcw, Snowflake } from "lucide-react";
import {
Box,
Group,
@@ -242,6 +242,28 @@ export function Step3CargoScope({
/>
)}
/>
+ {/* Empty-container return is a container-only service. Like hazardous,
+ enabling it here adds the return surcharge as a unit rate; at
+ booking time the customer/GL sets how many containers return. */}
+ {cargoType === "container" && (
+ (
+ }
+ iconBg="#EAF6EC"
+ iconColor="#1E7B34"
+ title="Empty Container Return"
+ description="EDR returns the empty containers — applies a per-container return surcharge."
+ checked={field.value === "with_return"}
+ onChange={(v) =>
+ field.onChange(v ? "with_return" : "without_return")
+ }
+ />
+ )}
+ />
+ )}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
index 80c04f081..8610753c9 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts
@@ -18,6 +18,12 @@ export interface ShipmentValidationContext {
isContainer: boolean;
isHazardous: boolean;
isReefer: boolean;
+ /**
+ * Contract was created with the empty-container return service
+ * (equipment_return = WITH_RETURN, container freight only). Enables the
+ * per-line "with return" quantity, validated like hazardous/reefer.
+ */
+ withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM";
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
@@ -52,6 +58,7 @@ const containerLineSchema = z.object({
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
hazardousQuantity: z.string().default("0"),
reeferQuantity: z.string().default("0"),
+ returnQuantity: z.string().default("0"),
units: z.array(containerUnitSchema).default([]),
});
@@ -143,6 +150,22 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
}
+ if (ctx.withReturnService) {
+ const w = Number(line.returnQuantity || 0);
+ if (w < 0) {
+ refineCtx.addIssue({
+ code: "custom",
+ path: ["containers", i, "returnQuantity"],
+ message: "Enter a valid return quantity.",
+ });
+ } else if (w > qty) {
+ refineCtx.addIssue({
+ code: "custom",
+ path: ["containers", i, "returnQuantity"],
+ message: `Can't exceed the ${qty} container(s) in this line.`,
+ });
+ }
+ }
});
} else {
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts
index ed4d6f0f4..28d503faf 100644
--- a/packages/types/src/freight/contracts.ts
+++ b/packages/types/src/freight/contracts.ts
@@ -733,6 +733,11 @@ export interface CreateBookingContainerLineDto {
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
+ /**
+ * How many units ship with empty-container return (≤ quantity). Only allowed
+ * when the contract was created WITH_RETURN (container freight only).
+ */
+ returnQuantity?: number;
units: CreateContainerUnitDto[];
}
From 41fe04652f095cfba7ff217ae4ba896a879323f3 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Thu, 16 Jul 2026 00:33:31 +0000
Subject: [PATCH 59/67] fix issue
---
apps/edr-freight-api/FREIGHT_QA_REPORT.md | 457 ++++++++++++++++++
.../2280000000000-WagonNumberPartialUnique.ts | 70 +++
.../src/modules/billing/billing.service.ts | 27 ++
.../src/modules/billing/payment.controller.ts | 38 +-
.../bookings/booking-invoice.service.ts | 47 +-
.../bookings/booking-pricing.service.ts | 200 +++++++-
.../bookings/booking-transition.service.ts | 8 +
.../modules/bookings/bookings.repository.ts | 95 +++-
.../src/modules/bookings/bookings.service.ts | 25 +-
.../src/modules/cargoes/cargoes.service.ts | 66 ++-
.../containers.service.ts | 47 +-
.../contracts/clearance-fee.service.ts | 21 +-
.../contracts/contract-transition.service.ts | 25 +-
.../modules/contracts/contracts.controller.ts | 21 +-
.../contracts/dto/create-contract.dto.ts | 8 +-
.../src/modules/drivers/drivers.controller.ts | 26 +-
.../src/modules/files/files.controller.ts | 14 +-
.../src/modules/files/files.service.ts | 40 +-
.../fuel/dto/create-fuel-purchase.dto.ts | 4 +-
.../src/modules/fuel/fuel.service.ts | 14 +-
.../import-operations.controller.ts | 5 +
.../modules/incidents/incidents.controller.ts | 12 +-
.../interchange-documents.controller.ts | 8 +
.../interchange-documents.service.ts | 27 +-
.../locomotives/locomotives.service.ts | 42 +-
.../maintenance/maintenance.service.ts | 48 +-
.../notifications/notifications.service.ts | 20 +-
.../strategies/notification.email.strategy.ts | 24 +-
.../src/modules/otp/otp.controller.ts | 3 +
.../src/modules/otp/otp.service.ts | 43 +-
.../src/modules/routes/routes.service.ts | 35 +-
.../priority-rule-change-requests.service.ts | 10 +
.../rule-engine/services/rates.service.ts | 7 +
.../dto/maintenance-reschedule.dto.ts | 16 +
.../scheduling-reschedule.controller.ts | 3 +-
.../scheduling-reschedule.repository.ts | 24 +-
.../scheduling-reschedule.service.spec.ts | 14 +-
.../scheduling-reschedule.service.ts | 102 +++-
.../compare-scheduling-priority.util.ts | 10 +-
.../modules/tracking/tracking.controller.ts | 9 +-
.../train-scheduling/booking-batch.service.ts | 49 +-
.../train-scheduling.service.ts | 83 +++-
.../src/modules/trains/trains.service.ts | 44 +-
.../modules/wagons/dto/update-wagon.dto.ts | 8 +-
.../src/modules/wagons/wagons.service.ts | 131 ++++-
.../ScheduleWorkspacePanel.tsx | 11 +-
.../components/wagons/AssignWagonDialog.tsx | 2 +-
.../wagons/WagonYardWorkspaceModal.tsx | 18 +-
.../src/pages/fleet/FleetResourcePage.tsx | 12 +-
.../src/pages/billing/InvoiceDetailPage.tsx | 24 +-
.../payments/PayClearanceFeeButton.tsx | 4 +-
51 files changed, 1895 insertions(+), 206 deletions(-)
create mode 100644 apps/edr-freight-api/FREIGHT_QA_REPORT.md
create mode 100644 apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts
create mode 100644 apps/edr-freight-api/src/modules/scheduling-reschedule/dto/maintenance-reschedule.dto.ts
diff --git a/apps/edr-freight-api/FREIGHT_QA_REPORT.md b/apps/edr-freight-api/FREIGHT_QA_REPORT.md
new file mode 100644
index 000000000..e6a39aa58
--- /dev/null
+++ b/apps/edr-freight-api/FREIGHT_QA_REPORT.md
@@ -0,0 +1,457 @@
+# EDR Freight — Full QA / Logic Audit Report
+
+**Date:** 2026-07-15
+**Scope:** `@edr/freight-api` + freight backoffice/portal web apps
+**Excluded per request:** warehouses, first-mile, last-mile, onboarding (Fayda/verifayda)
+**Environment:** live stack via `pnpm run dev:freight`, API on `http://localhost:3030`, DB `edr_freight` @ `10.18.7.207`
+**Method:** booted the real app, logged in as `superadmin@tria.com`, drove the API with `curl`, reproduced state bugs against live data, read every service/controller/entity in the in-scope modules, and cross-checked the backoffice screens against the API.
+
+---
+
+## 1. How to read this report
+
+Every finding has: **Problem** (what is wrong + concrete failure), **Impact**, **Fix** (code-level, with `file:line`), and — where I ran it live — a **Repro** block with the actual request/response.
+
+Severity:
+
+- **CRITICAL** — money moves wrongly, or anyone can act on anyone's data / settle invoices.
+- **HIGH** — data corruption, cross-tenant read/write, physical-asset state diverges from reality.
+- **MEDIUM** — wrong-but-recoverable state, missing guards, math/notification errors.
+- **LOW** — hardening, stale displays, latent (unused endpoint) bugs.
+
+**Counts:** 6 Critical · 18 High · 27 Medium · 20 Low (≈71 distinct issues).
+
+The single most valuable structural fix is at the top of §4 — it eliminates a whole family of bugs.
+
+---
+
+## 2. Live tests I actually ran (evidence)
+
+| # | Test | Result | Verdict |
+|---|------|--------|---------|
+| 1 | Login `superadmin@tria.com` | `success:true` + JWT | OK |
+| 2 | Build train `TR-00002` in KALITY, 2 locomotives | created | OK |
+| 3 | **Move coupled LOCO-004 to MOJO while train stays in KALITY** | `200`, train KALITY / loco MOJO | **BUG — your example, confirmed** |
+| 4 | Re-use same locomotive on a 2nd train | `409 already coupled` | Guard OK |
+| 5 | `PATCH /train-builder/:id/yard` → MOJO | coupled locos + wagons follow | OK (builder path correct) |
+| 6 | Decommission a coupled locomotive | `200 OUT_OF_SERVICE` | **BUG — no coupling guard** |
+| 7 | Attach KALITY wagon to MOJO train | `400 not in yard` | Guard OK |
+| 8 | `PATCH /wagons/:id` yard → different yard while ASSIGNED | `200` accepted | **BUG** |
+| 9 | `DELETE /wagons/:id` on a wagon coupled to a train | `200` + **row physically gone** | **BUG — hard delete, no guard** |
+| 10 | `GET /payments/checkout` **no auth** | `200` | **BUG — public** |
+| 11 | `POST /internal/payments/mark-paid` **no auth** | `400` (reached handler, not `401`) | **BUG — public** |
+| 12 | `GET /payments/receipt/:id` **no auth** | `400` (reached handler) | **BUG — public** |
+| 13 | `GET /files/:id` **no auth** | `404` (reached handler) | **BUG — public** |
+| 14 | Control: `GET /locomotives`, `GET /incidents` no auth | `401` | Auth global guard works |
+
+Tests 10–14 prove the "public" endpoints are genuinely reachable without a token (protected routes return `401`; these return `400`/`404`/`200` because they hit the handler).
+
+> **Boot-time noise (not app bugs):** SMS + Email services fail on RabbitMQ `ACCESS_REFUSED` (broker creds), and Swagger warns about 3 duplicate DTO names (`UpdateProfileDto`, `RequestChangesDto`, `SignContractDto`) and a legacy `/api/*` route. See §9.
+
+---
+
+## 3. CRITICAL — money & authorization
+
+### C1. Paying an invoice settles it instantly without any money moving
+**File:** `edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts:973-986`
+A shipped "DEMO" shortcut fakes a `payment.succeeded` callback the moment a payment is initiated:
+```ts
+// DEMO: manually fire the gateway 'payment.succeeded' callback here …
+if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", … }) }
+```
+Real gateways return `REQUIRES_ACTION` (redirect), so `immediateSuccess` is false for essentially every payment → the invoice is marked **PAID**, the booking advances to `PAID`/batch allocation, and the clearance-fee gate opens. **A customer clicks "Pay", closes the page, pays nothing, and the freight ships.** If they *do* pay, the later real webhook is a no-op and money is collected against an already-settled invoice with no reconciliation.
+**Fix:** delete the `if (!result.immediateSuccess)` block. Settle only from `settleByPaymentId` on a verified provider signal, and check `intent.amountMinor` against the invoice balance at settle time.
+
+### C2. Unauthenticated endpoints can settle any invoice by ID *(live-confirmed)*
+**Files:** `billing/payment.controller.ts:55-104` (`GET /payments/checkout`, `@Public()`), `payment/internal-payment.controller.ts:22-39` (`POST /internal/payments/mark-paid`, `@Public()` — its own comment: *"anyone who can reach the API can mark payments as paid"*), `billing/payment.controller.ts:39-53` (`POST /payments/initiate`, no guard, no ownership check).
+**Repro (live):**
+```
+GET /api/payments/checkout?invoiceId=…&method=TELEBIRR → 200 (no token)
+POST /api/internal/payments/mark-paid → 400 (no token, reached handler)
+GET /api/locomotives → 401 (control)
+```
+Combined with **C1**, anyone with an invoice UUID marks it paid unauthenticated and ships the freight. Anyone can POST a forged `payment.succeeded` (only a booking id is needed) to `mark-paid`.
+**Fix:** shared-secret / `x-service-token` guard on `mark-paid` (the payment API already sends one — `http-payment-event-publisher.ts:44-45`); require auth + ownership on `initiate`; make `checkout` a signed, expiring URL.
+
+### C3. Currency-unit chaos — providers disagree by 100× on the same charge
+**Files:** `billing.service.ts:958` sends `amountMinor: Math.round(Number(invoice.balanceAmount))` (invoice amounts are **major** units, `numeric(14,2)`). Providers then split: `cbe-birr.provider.ts:57`, `ebirr.provider.ts:64`, `card.provider.ts:60`, `cac-bank.provider.ts:307` all do `amountMinor / 100`; but `telebirr.provider.ts:201`, `dmoney.provider.ts:200`, `waafi.provider.ts:282` treat it as major (no `/100`).
+**Impact:** a 50,000 ETB invoice paid via CBE_BIRR/EBIRR/CARD/CAC charges **500.00** — while the freight side still marks it fully PAID (see C4). `Math.round` also drops cents.
+**Fix:** pick one convention (`Math.round(balance*100)` true-minor everywhere, fix the 3 major-unit providers), then enforce `confirmedAmountMinor === intent.amountMinor` at settlement.
+
+### C4. Settlement never verifies the amount charged
+**Files:** `billing.service.ts:527-552` (`markInvoiceAsPaid` sets `paidAmount = totalAmount, balanceAmount = 0` purely from the invoice); `edr-payment-api/.../intents/intents.service.ts:408-415` (an amount mismatch is only `logger.error`'d — the intent still finalizes SUCCEEDED, and no freight handler even populates `confirmedAmountMinor`).
+**Impact:** any wrong-amount success (C3's 1/100, a partial card capture, a reused stale intent) still marks the full invoice PAID.
+**Fix:** treat a `confirmedAmountMinor` mismatch as a failure/hold; in `settleByPaymentId` refuse (or record a partial) when the confirmed amount doesn't cover `balanceAmount`.
+
+### C5. Telebirr & D-Money webhook signatures are disabled
+**Files:** `edr-payment-api/.../webhooks/handlers/telebirr-webhook.service.ts:16-18` (`const signatureValid = true;` + `// TODO: re-enable`), `dmoney-webhook.service.ts:16-17` (same). The pipeline only rejects when `signatureValid` is false, so these two are trusted unconditionally on the public `/webhooks/*` surface.
+**Impact:** an attacker who guesses a `merch_order_id` POSTs a fake success payload → intent SUCCEEDED → freight marks the invoice PAID and ships.
+**Fix:** implement `verifyWebhookSignature` for both; until keys exist, re-query the provider (`queryStatus`) before honoring SUCCEEDED.
+
+### C6. Customers can create/act-on bookings billed to any company
+**Files:** `bookings/bookings.service.ts:594-629` (`create` only resolves+verifies the caller's own company when `dto.companyId` is *absent*; a supplied `companyId` is used verbatim), plus a batch of booking mutations with **no ownership check** in `bookings.controller.ts`: `PATCH /:id` (205), `DELETE /:id` (585), `POST /:id/generate-price` (611), `/submit` (622), `/confirm-submit` (633), `/reject` (643), `/clearance/documents` (682), `/clearance/proceed` (699), `GET /:id/clearance` (672). Reads (`GET /:id`) *do* call `assertCustomerCanAccessBooking`; writes don't.
+**Impact:** Company A's user sets `companyId` to Company B and books/invoices under B; or calls `DELETE /bookings/{B's id}` / submits / uploads clearance docs on B's booking. UUIDs appear in list payloads, so they're discoverable within a session.
+**Fix:** require a staff permission to pass `companyId`; otherwise force it from the resolved user company. Add `assertCustomerCanAccessBooking` to every mutating booking route.
+
+---
+
+## 4. HIGH — Train / Locomotive / Wagon consist integrity
+
+> **★ Structural root cause (fix this first).** The invariant *"a consist (train + its locomotives + its wagons) moves and locks as one unit"* is enforced **only inside** `trains/train-builder.service.ts`. Every *legacy / master-data* endpoint around it — `PATCH /locomotives/:id`, `PATCH /wagons/:id`, `POST /wagons/:id/assign-train`, `wagons/bulk-status`, `wagons/bulk-transfer`, `DELETE /trains/:id`, `DELETE /wagons/:id` — mutates the same rows with **no coupling guard**. Findings H1, H2, H5, H6, H7 are all the same missing check. **The one fix that kills the family:** make `TrainLocomotive`/`wagon.trainId` membership a guard that every locomotive/wagon mutation consults (reject or redirect to the builder endpoints).
+
+### H1. ★ Locomotive yard/status freely editable while coupled to a built train — *your example, confirmed live*
+**File:** `locomotives/locomotives.service.ts:87-119` (`update`).
+`update()` applies `status` and `currentYardId` with **no check of the `train_locomotives` link table**:
+```ts
+currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null),
+```
+The builder keeps train + locomotives in the same yard (`train-builder.service.ts:389-429 setYard`, `:656-660 validateAndLockLocomotives`), but `PATCH /locomotives/:id` bypasses it. **The frontend triggers it by accident:** the locomotive edit form always includes the `status` and `currentYardId` selects (`.../pages/fleet/config/resources.ts:168-169`) and PATCHes the *whole form object* on every save (`FleetResourcePage.tsx:301`). So editing a coupled locomotive's *name* re-sends its yard → the train and its locomotive end up in different yards.
+**Repro (live):**
+```
+Build TR-00002 in KALITY with LOCO-004 (+LOCO-023)
+PATCH /api/locomotives/LOCO-004 {"currentYardId": MOJO} → 200
+GET train → yard: KALITY ; LOCO-004 → yard: MOJO ← diverged, no error
+```
+**Fix (exactly what you described — reject with a clear error, don't silently move):**
+```ts
+const link = await this.ds.getRepository(TrainLocomotive)
+ .findOne({ where: { locomotiveId: id }, relations: { train: true } });
+if (link) {
+ if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId)
+ throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; move the train instead`);
+ if (dto.status !== undefined && dto.status !== loco.status)
+ throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; detach it before changing status`);
+}
+```
+Frontend: send only dirty fields, and disable the yard/status selects (show the modal error) when the locomotive is in a built train.
+
+### H2. Wagon PATCH is a free-for-all — yard, `trainId`, `sequenceNumber`, `status` all unguarded *(live-confirmed)*
+**File:** `wagons/wagons.service.ts:90-101` — `Object.assign(wagon, dto)` with zero invariant checks; `UpdateWagonDto` (PartialType of Create) exposes `trainId`, `sequenceNumber`, `status`, `currentYardId`.
+**Repro (live):** `PATCH /api/wagons/:id {"currentYardId": MOJO}` on a wagon `ASSIGNED` to a KALITY train returned `200` and moved it. Setting `trainId` directly attaches to any train bypassing every builder rule; flipping `ASSIGNED→AVAILABLE` while `trainId` is set makes the wagon grabbable by transfer requests and the legacy assign flow.
+**Fix:** reject `trainId`/`sequenceNumber` in update; when `wagon.trainId != null`, reject `currentYardId`/`status` changes (409 → point at train-builder endpoints).
+
+### H3. `DELETE /wagons/:id` hard-deletes with no guard — destroyed real data during this audit *(live-confirmed)*
+**File:** `wagons/wagons.service.ts:136-139` — `this.wagonRepo.remove(wagon)` is a **hard** delete (repo standard is soft-delete via `BaseEntity.deletedAt`). No check for `trainId`, live-schedule pinning, or containers.
+**Repro (live):** I called `DELETE /api/wagons/{BW1-0009}` while it was `ASSIGNED` to my test train → `200`. Raw SQL then showed **the row physically gone** (`SELECT … WHERE id=… → 0 rows`). On delete, `train_set_wagons.physical_wagon_id` is `SET NULL` (a dispatched schedule silently loses its physical wagon) and `wagon_movements` is `CASCADE` (the audit ledger is destroyed).
+> **I recreated BW1-0009 via the API** (`POST /api/wagons`, new UUID `d9b578ed-…`, AVAILABLE @ KALITY) so the dev data count is whole again. The original UUID `905cad62-…` is unrecoverable (hard delete). See §10.
+**Fix:** block deletion when `trainId IS NOT NULL` or the wagon is pinned to a DRAFT/SCHEDULED/DISPATCHED schedule (reuse `train-builder`'s `isWagonPinnedToLiveSchedule`); switch to `softRemove` (add a partial unique index on `wagon_number WHERE deleted_at IS NULL`).
+
+### H4. `DELETE /trains/:id` hard-deletes a built train and permanently strands its wagons
+**File:** `trains/trains.service.ts:57-60` — `trainRepo.remove(train)`, no active-schedule check (contrast `train-builder.service.ts:536-563 disband`, which blocks on live schedules and releases resources). Wagons' FK is `SET NULL`, so they keep `status = ASSIGNED` with `trainId = null` → **unusable forever** (attach requires `status === Available`). The fleet Trains CRUD page (`resources.ts:204`) points at this endpoint.
+**Fix:** delegate `TrainsService.remove` to `TrainBuilderService.disband` (block live schedules, reset wagons to Available, delete locomotive links), and use `softRemove`.
+
+### H5. Legacy wagon assign: maintenance wagons assignable, silent theft, duplicate sequences
+**File:** `wagons/wagons.service.ts:141-164` (`POST /wagons/:id/assign-train`). Only guard is `status === Assigned`. Holes vs the builder's `attachWagons`: a `MAINTENANCE`/`DETAINED` wagon is accepted and flipped to ASSIGNED (erasing the flag); a wagon with `trainId` set but status≠ASSIGNED has its `trainId` overwritten (**steals it from another train's consist**, never resequenced); no wagon-yard vs train-yard check; no `train.status !== IN_SERVICE` check; caller `sequenceNumber` isn't collision-checked (no unique index on `(train_id, sequence_number)`).
+**Frontend echo:** `AssignWagonDialog.tsx:22-24` filters `w.status === Available || !w.trainId` — the `||` should be `&&`; today it offers maintenance/detained wagons.
+**Fix:** re-implement on builder rules (`Available && trainId === null && sameYard && train not InService`, sequence = max+1), or delete the endpoint and use `POST /train-builder/:id/wagons`.
+
+### H6. `bulk-status` / yard-workspace "Free up" corrupts wagons physically in a train
+**File:** `wagons/wagons.service.ts:241-269` (`bulkSetStatus`, no `trainId` guard); UI `WagonYardWorkspaceModal.tsx:187,270-287,545-580` picks an arbitrary slice of ASSIGNED wagons and flips them AVAILABLE — including consist wagons (whose ASSIGNED means "coupled"). After the flip they still have `trainId` set but read AVAILABLE → transfer requests hand them out, `bulk-transfer` moves them, legacy assign re-homes them, while the builder consist still lists them.
+**Fix:** refuse to change status of wagons with `trainId IS NOT NULL`; exclude them from the modal's flip pools.
+
+### H7. `bulk-transfer` relocates train-coupled wagons; legacy unassign bypasses the schedule pin
+**File:** `wagons/wagons.service.ts:180-234` (`bulkTransfer`, existence-only check → moves consist wagons to another yard) and `166-172` (`unassignFromTrain` frees a wagon with no `isWagonPinnedToLiveSchedule` check and leaves a sequence gap).
+**Fix:** reject `trainId IS NOT NULL` in `bulkTransfer`; run the builder's pinned-schedule query in `unassignFromTrain` and resequence, or delete in favor of `DELETE /train-builder/:id/wagons/:wagonId`.
+
+### H8. Export capacity check-then-act race → train overbooking
+**File:** `bookings/booking-transition.service.ts:1093-1153` → `train-scheduling/booking-batch.service.ts:722-793` — `pickExportSchedule` reads `budget.fits(...)` then `reserve` (idempotency only, never re-checks capacity). No transaction/row-lock spans the check and the write (the code comment even calls the pre-check "rough").
+**Impact:** two staff accept two export bookings for the same near-full train concurrently → both pass `fits()`, both reserve → train exceeds locomotive pull weight / wagon slots.
+**Fix:** wrap check + reserve in a serializable transaction with `SELECT … FOR UPDATE` on the schedule (or a per-schedule advisory lock); re-verify `fits()` inside the lock.
+
+### H9. Consolidation pairing race → one partner paired with two bookings
+**File:** `bookings.service.ts:494-543` + `bookings.repository.ts:213-261,287-309` — `findComplementaryConsolidationPartner` filters `consolidationPartnerId IS NULL`, `pairConsolidation` writes both sides, no lock between find and pair.
+**Impact:** two bookings both pick the same waiting partner → asymmetric pairing, shared-wagon capacity double-counted.
+**Fix:** `FOR UPDATE` on the candidate inside a transaction, re-assert `consolidationPartnerId IS NULL` on both before writing.
+
+### H10. `import-operations` controller has no authorization
+**File:** `import-operations/import-operations.controller.ts` (whole file — only `@ApiTags`, no permission guard). Any authenticated portal user can `POST /import-operations/customs/{anyBookingId}/release-permitted`, assign customs risk, or mark duties/taxes paid on any booking.
+**Fix:** add `@BookingStaff(FREIGHT_PERMS…)` guards.
+
+### H11. `interchange-documents` controller has no authorization
+**File:** `interchange-documents/interchange-documents.controller.ts` — `POST /generate-from-schedule`, `PATCH /:id/acknowledge|dispute|cancel` reachable by any authenticated user. These are outward-facing customs/port handover records.
+**Fix:** staff permission guards.
+
+### H12. Anyone can sign anyone's contract as CUSTOMER
+**File:** `contracts/contracts.controller.ts:502-523` (asserts permission only for non-CUSTOMER roles) + `contract-transition.service.ts:795-816` (CUSTOMER branch checks only status + a **caller-supplied** `dto.otpPhone`/`dto.otp`). Any portal user signs another company's CONTRACT_READY contract with an OTP on their own phone → SIGNED_CUSTOMER, firing counter-sign/clearance-fee. `renew` (525) and `clearance/documents` (543) have the same gap.
+**Fix:** call `assertCustomerCanAccessContract`; verify OTP against the contract company's registered phone, not `dto.otpPhone`.
+
+### H13. Any file downloadable by anyone with the UUID *(live-confirmed public)*
+**File:** `files/files.controller.ts:20-55` — the single global file-stream route is `@Public()`, no auth, no ownership, no expiry. All uploads funnel through it: driver documents, contract PDFs, company license / Fayda national-ID files.
+**Repro (live):** `GET /api/files/{uuid}` → `404` with no token (reached handler; a valid id streams the file). Control routes return `401`.
+**Fix:** require `JwtGuard` and authorize by the file's `resource`/`resourceId`, or serve via short-lived signed URLs (the code already has `filesService.signUrl`).
+
+### H14. Duplicate `payment.succeeded` regresses advanced/cancelled bookings back to PAID
+**File:** `bookings/booking-invoice.service.ts:133-166` — the idempotency guard `// if (booking.paymentStatus === "PAID") return;` is **commented out**, then it unconditionally rewrites `status: "PAID"` and re-runs allocation. The relay is documented at-least-once and freight never dedupes `eventId` (the `PaymentWebhookEventEntity` is registered but unused).
+**Impact:** a replayed success on a `SCHEDULED`/`DISPATCHED` — or `CANCELLED` — booking force-rewrites it to PAID and re-fires side effects.
+**Fix:** restore the guard as a state-machine check (only advance from awaiting-payment statuses); persist processed `eventId`s.
+
+### H15. Booking prices ignore the contract's frozen rate snapshots
+**File:** `bookings/booking-pricing.service.ts:123-229` prices exclusively from `ratesService.findLiveRates()`; nothing reads `contract_rate_snapshots` (only the clearance fee honors the freeze). `contract-booking.service.ts:289-295` comments "compute from contract unit rates" but calls the live-rate path.
+**Impact:** customer signs a contract at 1,916 USD/40ft, rates team raises the live rate to 2,300, the drawdown booking bills 2,300 — contradicting the signed contract PDF.
+**Fix:** in `computePriceForBooking`, when `booking.contractId` is set, resolve unit prices from that contract's snapshots (fall back to live only for un-frozen codes).
+
+### H16. Cancelling a booking leaves its invoice open & payable; refund path is dead code
+**File:** `booking-transition.service.ts:519-543` (`cancel()` writes a note + `status: CANCELLED`, never cancels the open PREPAID invoice) and `billing.service.ts:654-672` (`markInvoiceAsRefunded` has **zero callers**).
+**Impact:** the cancelled booking's invoice stays payable in the portal; paying it fires `booking.invoice.paid` → flips the CANCELLED booking back to PAID (see H14). Money already collected on a later-cancelled flow has no refund mechanism.
+**Fix:** `cancel()`/`reject()` must cancel/expire the open invoices in the same transaction; wire `markInvoiceAsRefunded` to a real staff refund endpoint; make `advanceBookingOnPayment` refuse terminal-status bookings.
+
+### H17. `cancelTrainSchedule` has no status guard — a DISPATCHED/ARRIVED train can be cancelled
+**File:** `train-scheduling/train-scheduling.service.ts:3226-3311` (unguarded at `train-scheduling.controller.ts:817`). Every sibling transition checks `status`; this one goes straight to the cancel transaction.
+**Impact:** cancelling a DISPATCHED train sets every pinned wagon's `currentYardId = originStationId` ("never left") while they're rolling; releases the locomotives out on this run to AVAILABLE; detaches IN_TRANSIT bookings while their cargo stays IN_TRANSIT. The frontend only shows Cancel for `DRAFT`/`SCHEDULED` (`TrainScheduleV2ListPage.tsx:439-469`) — the API enforces nothing.
+**Fix:** reject unless `['DRAFT','SCHEDULED'].includes(schedule.status)`.
+
+### H18. `executeReschedule` is not transactional — a mid-flight failure strands the schedule half-rescheduled
+**File:** `scheduling-reschedule/scheduling-reschedule.service.ts:131-210` — four separately-committed steps (persist new date → unassign each displaced booking → `assignBookingsToSchedule` → audit event). Step 3 routinely throws (re-validates against the *new* date persisted in step 1). Result: date already moved, displaced bookings already gone, no audit event, `400` to staff.
+**Fix:** run the whole execute in one `dataSource.transaction`, threading the manager through unassign/assign.
+
+---
+
+## 5. MEDIUM — scheduling, bookings, cargo, containers
+
+### M1. Cargo delivery never releases the container (counts itself)
+**File:** `cargoes/cargoes.service.ts:159-175` — sets `cargo.status = 'DELIVERED'` in memory, then counts `LOADED` cargo on the container *before saving*, so the cargo being delivered counts itself → `remaining >= 1` always → the container is **never** flipped back to AVAILABLE.
+**Fix:** save the cargo first, or exclude the current id: `count({ where: { containerId, status: 'LOADED', id: Not(cargo.id) } })`.
+
+### M2. Cargo unload leaves the container marked LOADED
+**File:** `cargoes.service.ts:139-147` — `unloadCargo` sets cargo `UNLOADED` but never touches `container.status` (which `loadCargo` set to LOADED). Emptied containers read as in-use forever.
+**Fix:** on unload, if no remaining LOADED cargo references the container, reset it to AVAILABLE.
+
+### M3. Container↔wagon assignment: no capacity/duplicate guard; status wrongly AVAILABLE
+**File:** `container-management/containers.service.ts:113-136` — `MAX(position)+1` check-then-act with no `(wagon_id, position)` unique constraint; no check that the container is already on another wagon (silently overwrites `wagonId`); sets `status = 'AVAILABLE'` for a container physically on a wagon (so it reads free for another assignment). *(There's also a dead `containers.service copy.ts` duplicate.)*
+**Fix:** reject when the container already has a `wagonId`; enforce wagon capacity; unique `(wagon_id, position)` + allocate in a transaction; use a distinct on-wagon status.
+
+### M4. Interchange document state machine holes
+**File:** `interchange-documents/interchange-documents.service.ts:182-206` — `dispute` has **no status guard** (a CANCELLED or ACKNOWLEDGED doc can be flipped to DISPUTED); `acknowledge` guards only CANCELLED, so an already-DISPUTED doc can be quietly ACKNOWLEDGED (losing the dispute).
+**Fix:** restrict `dispute` to GENERATED/ACKNOWLEDGED; restrict `acknowledge` to GENERATED.
+
+### M5. Interchange item weight mixes tons and kg in one column
+**File:** `interchange-documents.service.ts:238-361` — the `weight` column is `COALESCE`d from tons sources (`total_vgm_tons`, `cargo_total_weight_vgm`) *and* kg sources (`containers.max_gross_weight`, `cargoes.weight // kg`). Different line items in the same customs handover carry weights ~1000× apart.
+**Fix:** normalize every source to one unit before writing.
+
+### M6. Cargo load/create allow weight over container capacity
+**File:** `cargoes.service.ts:114-137` — `loadCargo` sets `cargo.weight = dto.weight` (`@Min(0)` only), never compared to the container's `maxGrossWeight`, no aggregate across cargoes.
+**Fix:** verify `tare + sum(loaded) ≤ maxGrossWeight` (mind M5's units) and reject overflow.
+
+### M7. Reschedule changes the departure date without any of `updateScheduleDate`'s validation
+**File:** `scheduling-reschedule.service.ts:151-157` vs `train-scheduling.service.ts:781-865` — the reschedule/maintenance path writes `scheduledDepartureDate` directly: no `PRE_WINDOW` check, no lead-window rejection, no re-derivation of `windowOpensAt/ClosesAt/Phase`, no route+day group re-anchor, and **past dates are accepted** (never compared to `now`; only the dialog checks client-side). Windows keep the timing computed for the OLD date.
+**Fix:** delegate to `updateScheduleDate` (or replicate its checks) and validate `newDepartureDate > now`.
+
+### M8. `compareSchedulingPriority` sorts null-date bookings FIRST (comment says last)
+**File:** `scheduling/compare-scheduling-priority.util.ts:4-6,20-22` — null `scheduledDate` → `getTime()` falls back to `0` (epoch), ascending sort puts it first. In `previewReschedule` this decides who is retained when capacity is tight → a general-contract booking with no date outranks customers who booked a concrete slot.
+**Fix:** fall back to `Number.MAX_SAFE_INTEGER`, not `0`.
+
+### M9. Assigning bookings never checks the booking's day matches the schedule's departure day
+**File:** `train-scheduling.service.ts:1226-1259` + `3321-3618` — the parity guard's comment claims day-fit is enforced "downstream", but `validateBookingsForScheduling` never reads `dto.scheduleDate` or compares `booking.scheduledDate`. A booking a customer picked for Jul 25 can be assigned to a train departing Jul 17, silently.
+**Fix:** add a violation (or `forceAssign` warning) when `eatDay(booking.scheduledDate) !== eatDay(schedule departure)`.
+
+### M10. Partial `unassignBooking` frees the booking but not its wagon slots/totals
+**File:** `train-scheduling.service.ts:1492-1541` — `TrainSetWagon` slots and `TrainSet` aggregates are only reset when the train becomes fully empty. Remove 1 of 3 bookings → tonnage/length/wagonCount stay stale, empty slots stay RESERVED with wagons pinned, free-capacity under-reports (can hide the day from customers), dispatch sends the empty pinned wagons.
+**Fix:** after a partial unassign, release the emptied slots and recompute totals from surviving allocations.
+
+### M11. Displaced-booking fallback leaves the booking still linked to the schedule
+**File:** `scheduling-reschedule.service.ts:159-168` — on unassign failure the `catch` only flips scheduling fields; it doesn't delete the `TrainScheduleBooking` link, clear `trainScheduleId`, or free allocations. The booking becomes ELIGIBLE for batch fills **and** still linked → double-booking; its stale `trainScheduleId` also blocks manual assignment elsewhere.
+**Fix:** in the fallback, delete the link + allocations and set `trainScheduleId: null` (or re-throw and abort).
+
+### M12. Reschedule notifies "rescheduled to a new date" even when the date didn't change
+**File:** `scheduling-reschedule.service.ts:204-235` — `effectiveDeparture` falls back to the (never-null) existing date, so the `if (newDeparture)` branch always runs → a GOVERNMENT_PREEMPT rebalance with no date change SMS/email-blasts every retained customer "rescheduled to ``".
+**Fix:** `const effectiveDeparture = dto.newDepartureDate ? new Date(dto.newDepartureDate) : null;`
+
+### M13. Maintenance reschedule endpoint bypasses DTO validation & drops caller bookings
+**File:** `scheduling-reschedule.controller.ts:56-65` (body typed as an intersection `PreviewRescheduleDto & { … }` → Nest emits `Object` metadata → **ValidationPipe is skipped**, so `newDepartureDate: "garbage"` and missing arrays reach the service) + `scheduling-reschedule.service.ts:257-283` (preview uses `currentIds.length ? currentIds : dto.incomingBookingIds`, so caller-supplied incoming ids are dropped on a non-empty train, then execute re-runs with `dto.incomingBookingIds` → the displaced-set equality check can 400).
+**Fix:** real `MaintenanceRescheduleDto extends PreviewRescheduleDto` with `@IsDateString() newDepartureDate`; merge `currentIds ∪ dto.incomingBookingIds` for both preview and execute.
+
+### M14. Routes editable (milestones deleted, endpoints swapped) while live schedules reference them
+**File:** `routes/routes.service.ts:108-140` — `update()` deletes+rewrites milestones and origin/destination with no check for DRAFT/SCHEDULED/DISPATCHED schedules on the route. Schedules read the corridor live afterward (sub-leg validation, checkpoints, customer day pool), so a reroute silently invalidates boarding bookings and renumbers stations mid-run.
+**Fix:** reject milestone/endpoint changes when any non-terminal schedule references the route (allow status-only edits).
+
+### M15. Workspace capacity meter sums locomotive limits; API caps at the weakest locomotive
+**File:** `.../components/trainScheduling/ScheduleWorkspacePanel.tsx:98-109` (`reduce(sum + maxPullWeightTons)`) vs `train-capacity.util.ts:242-260` + service `1349-1389` (`minLocomotiveLimits` — the weakest locomotive caps the train; gate is cargo **+ consist tare**). Two 3500T locos → the meter shows 7000T and "43% full" while the API already rejects adds at ~3500T gross; the overfill warning fires on the wrong threshold.
+**Fix:** capacity = `min(maxPullWeightTons) + min(overageToleranceTons)`, and include consist tare in `used`.
+
+### M16. Vehicles under maintenance stay assignable; maintenance never changes availability
+**File:** `maintenance/*` never writes `vehicle.status`/availability; `maintenance.service.ts:36-46` COMPLETED doesn't restore anything; assignment paths check only availability, never `VehicleStatus.MAINTENANCE`.
+**Impact:** a vehicle in the shop can be dispatched; marking a vehicle MAINTENANCE doesn't block assignment.
+**Fix:** on maintenance start set the vehicle unavailable, on completion restore, and reject MAINTENANCE/OUT_OF_SERVICE/RETIRED at assignment.
+
+### M17. Fuel purchases accept negative/zero quantities, no duplicate guard
+**File:** `fuel/dto/create-fuel-purchase.dto.ts:11-15` (`liters`, `costPerLiter` are `@IsNumber()` only) → `fuel.service.ts:18` `totalCost = liters * costPerLiter`. Negative liters → negative monthly totals & averages, poisoning Financial Reports/Fleet Dashboard; no `(vehicleId, receiptNumber)` uniqueness → double-counting.
+**Fix:** `@IsPositive()` on both; reject duplicate `(vehicleId, receiptNumber)`.
+
+### M18. Public OTP verify: no expiry, no rate limit, no attempt cap, replayable
+**File:** `otp/otp.service.ts:94-120` (`verifyOtp`, exposed `@Public()` at `otp.controller.ts:22-63`) — a 6-digit code (1e6 space) with unlimited attempts, no age check, and only flagged `verified=true` on success (the same code keeps working). The hardened `verifyOtpForAction` (TTL + 5-attempt cap + delete-on-success) exists but this route doesn't use it.
+**Fix:** give `verifyOtp` the same TTL/attempt-cap/consume semantics; rate-limit the public OTP routes.
+
+### M19. OTP generated with `Math.random()` (not a CSPRNG)
+**File:** `otp/otp.service.ts:27-29` — this gates password reset (`forgot-password.service.ts:134`) and contract-signature sudo. Predictable codes weaken account-takeover resistance.
+**Fix:** `crypto.randomInt(100000, 1000000)`.
+
+### M20. OTP send is public & unthrottled — SMS/email bombing + counter reset
+**File:** `otp/otp.controller.ts:33-42` (`@Public() POST /otp/send`) + `otp.service.ts:35-88` (each send does `actionAttempts.delete(...)`, resetting the in-memory brute-force counter — attacker-controllable, and per-process anyway).
+**Fix:** rate-limit per target + per IP; move the attempt counter to persistent storage.
+
+### M21. Email notifications silently discarded while reporting success
+**File:** `notifications/strategies/notification.email.strategy.ts:8-11` — `send()` logs and `return false;` (stub). `notifications.service.ts:25-32 directSend` awaits it, logs `is sent - false`, and neither throws nor surfaces the failure. Every email notification (booking lifecycle, contracts, companies, booking-window) silently never sends.
+**Fix:** implement the strategy (or route through the working `EmailClientService`); make `directSend` treat `false`/throw as an observable failure.
+
+### M22. Rate "CEO approval" is self-approvable (no segregation of duties)
+**File:** `rule-engine/services/rates.service.ts:186-208` + `rates.controller.ts:61-76` — `submit` and `approve` share the identical `@RuleEngineManage('rates')` permission, and `approve` never checks `approverUserId !== proposedByStaffId`. One staffer can draft→submit→approve LIVE. (Same in `priority-rule-change-requests.service.ts:78-112`.)
+**Fix:** distinct approver permission + reject self-approval.
+
+### M23. Uploads accept arbitrary type & unbounded size
+**File:** `drivers/drivers.controller.ts:74-82` (`AnyFilesInterceptor()`, no `limits`/`fileFilter`) + `files/files.service.ts:36-55` (stores whatever mime/size). `file.buffer` held in memory → DoS; executables/active-HTML then served inline via the public files route (H13). The `file-upload-settings` config exists but isn't enforced.
+**Fix:** Multer `limits.fileSize` + mime allowlist (driven by file-upload-settings), validated in `FilesService.upload`.
+
+### M24. Incidents controller: full CRUD + IDOR for any authenticated user
+**File:** `incidents/incidents.controller.ts:17-69` — no permission guard on any route (sibling fleet modules all use `@BookingStaff`). A portal customer can read any driver's incident history by `driverId` and create/alter/delete incident records.
+**Fix:** class-level `@BookingStaff(FREIGHT_PERMS.incidents.view)` + per-write permissions.
+
+### M25. Tracking timeline IDOR
+**File:** `tracking/tracking.controller.ts:11-17` — `GET /:consignmentId` has no guard/ownership check; any logged-in user reads any consignment's full movement history by iterating UUIDs.
+**Fix:** permission guard + scope to the caller's company.
+
+### M26. Clearance-fee gate silently waived when the fee snapshot is missing
+**File:** `contracts/clearance-fee.service.ts:80-87` — `gateApplies` returns `false` (skipping the prepay gate, warn-log only) whenever no `CUSTOMS_CLEARANCE` snapshot line exists, and `contract-pricing.service.ts:262-284` falls back to a stale stored breakdown. A customs contract whose price predates the fee feature ships clearance for free.
+**Fix:** for `customsClearingEnabled` contracts, hard-fail counter-sign/shipment-request when no fee line resolves.
+
+### M27. Manual settlement / `updateStatus` gaps in the invoice state machine
+**File:** `billing.service.ts:577-609` (`recordPayment` guards Cancelled/Refunded/Paid but not Draft/Expired → an expired invoice can be settled at the counter, resurrecting a released flow) and `889-907` (`updateStatus` accepts **any** target including Paid without touching `paidAmount`/`balanceAmount` → a PAID invoice with a full outstanding balance).
+**Fix:** add Draft/Expired to the reject list; restrict `updateStatus` to the Draft→Issued transition it's actually used for.
+
+---
+
+## 6. LOW — hardening & latent bugs
+
+- **L1. Decommission ignores coupling** — `locomotives.service.ts:121-133` sets OUT_OF_SERVICE with no `TrainLocomotive` check *(live-confirmed: decommissioned a coupled loco, 200)*. Train keeps hauling with a dead loco on paper; `capacityTons` not recomputed. → link-table guard.
+- **L2. Builder accepts ASSIGNED/UNAVAILABLE locomotives** — `train-builder.service.ts:653` blocks only OUT_OF_SERVICE/MAINTENANCE; a loco out on a dispatched run (ASSIGNED) can be coupled to a new train. → allowlist `AVAILABLE/IMPORT_READY/EXPORT_READY`.
+- **L3. Legacy `PATCH /trains/:id`** — `trains.service.ts:50-55` `Object.assign` lets `status`/`capacityTons`/`code`/`trainNumber` be rewritten (unfreeze a dispatched train, break the weakest-loco capacity, code/number clashes → 500 or silent collision). → strip those fields; run the builder's clash query.
+- **L4. `PATCH /train-builder/:id/details` skips even/odd run-number rule** — `update-train-details.dto.ts:17-29` lacks the `@Matches(/…[13579]$/ | …[02468]$/)` the build DTO enforces → can set IMPORT number odd. → copy the decorators.
+- **L5. Legacy reorder ignores `trainId`** — `wagons.service.ts:271-287` (`_trainId` unused) renumbers any wagon list to `1..n` with no set-equality check → duplicate sequences on other trains. → validate set equality vs `find({ where: { trainId } })`.
+- **L6. Wagon type soft-deletable while in use** — `wagon-types.service.ts:114-117` no reference check; soft-deleted type → `wagon.wagonType` loads null → builder length/tare math silently zeroes. → block when wagons reference it.
+- **L7. Facilities create/update are dead** — `facilities/dto/*.dto.ts` are plain interfaces (no decorators) under a `forbidNonWhitelisted` pipe → every non-empty body 400s, empty body 500s. → decorate the DTOs + code-uniqueness check.
+- **L8. Yard soft-delete has no reference guard** — `rule-engine/services/yards.service.ts:62-66` strands trains/locos/wagons parked there (their `current_yard_id` scalar survives but joins miss). → count references, 409.
+- **L9. Wagon create incoherent state** — `wagons.service.ts:27-37` accepts `trainId` while status defaults AVAILABLE (violates attached⇒ASSIGNED); duplicate `wagonNumber` → 500 not 409. → drop `trainId`/`sequenceNumber` from create DTO, uniqueness pre-check.
+- **L10. Locomotive code generation is a read-scan race** — `locomotives.service.ts:40-48` scans all rows for max `LOCO-NNN`; concurrent creates collide → 500. → `MAX(SUBSTRING…)` + probe loop or a sequence.
+- **L11. `Train.capacityTons` stale after a coupled loco's pull limit is edited** — `locomotives.service.ts:87-119` has no recompute hook; list vs detail disagree. → recompute for trains found via `TrainLocomotive`.
+- **L12. `GET /facilities/:id` returns `200 null`, `DELETE` succeeds for unknown ids** — `facilities.service.ts:20-30` (every sibling 404s). → throw NotFound.
+- **L13. Import customs flow has no ordering/terminal guards** — `import-operations.service.ts:102-210`: duties-paid before notify, risk changed after completion, no terminal lock. → state + `completedAt` guards.
+- **L14. Consignment create doesn't validate the referenced booking** — `consignments.service.ts:14-17` persists the DTO directly (`bookingId` only `@IsUUID`). → existence check.
+- **L15. Incident & maintenance status transitions unvalidated** — `incidents.service.ts:64-71`, `maintenance.service.ts:36-46` accept any status (REPORTED→CLOSED, SCHEDULED→COMPLETED). → allowed-transition checks.
+- **L16. Maintenance cost fields accept negatives** — `maintenance/dto/create-maintenance.dto.ts` `estimatedCost/actualCost/costAmount` no `@Min(0)`. → add it.
+- **L17. Procurement depreciation accepts inverted money** — `procurement/dto/procurement.dto.ts:89-99` (no `@Min(0)`) + `procurement.service.ts:120-131`: `salvageValue > cost` → negative monthly depreciation → `bookValue` grows unbounded. → `@Min(0)` + `salvage <= cost`.
+- **L18. `notification-inbox` fan-out is sequential N+1** — `notification-inbox.service.ts:62-64` per-recipient create+countUnread+findOne. → batch/parallelize (it's fire-and-forget, so slow not broken).
+- **L19. GPS history `limit` unguarded vs NaN/negative** — `gps-tracking.controller.ts:38` passes `parseInt` through; `?limit=abc`→NaN, `?limit=-5`→negative take. → clamp to a positive int.
+- **L20. Hardcoded default JWT secret** — `@edr/api-common` `shared-auth.module.js` falls back to a public constant if `JWT_SECRET` is unset (mitigated by the session-row check). → ensure `JWT_SECRET` always set in the environment.
+
+---
+
+## 7. Backoffice / portal frontend mismatches (already folded into findings above)
+
+- **Locomotive/Wagon fleet edit forms PATCH the whole form object** (`FleetResourcePage.tsx:301`, `resources.ts:168-169,273-279`) → the accidental trigger for H1/H2.
+- **`AssignWagonDialog.tsx:22-24`** — `status === Available || !w.trainId` should be `&&` (H5).
+- **`WagonYardWorkspaceModal.tsx`** — "Assigned→Available" flip picks an arbitrary slice including coupled wagons (H6).
+- **`ScheduleWorkspacePanel.tsx:98-109`** — capacity meter sums loco limits instead of the weakest (M15).
+- **`InvoiceDetailPage.tsx:129-131` / `PayClearanceFeeButton.tsx:121-125`** — pay button shows `totalAmount`, backend charges `balanceAmount`; the correct `amountDue` line is commented out → a 60%-paid invoice tells the customer they'll pay the full total. Also a leftover `console.log(paymentMethod)` at line 71.
+- **Reflected XSS on the public checkout page** — `billing/payment.controller.ts:100-103,156-176` interpolates provider error `message`/`status`/`intentId` unescaped into `@Public()` HTML. → HTML-escape or return a generic message.
+- **Portal tracking/consignments pages are still mock-backed** (`portal/src/pages/tracking/shipments.mock.ts`, `consignments.mock.ts`) — they show mock data, not live consignments.
+
+---
+
+## 8. What is working correctly (verified, not bugs)
+
+So the report is balanced — these were checked and are sound:
+
+- **Train-builder same-yard enforcement** at build and attach (rejected my cross-yard attempts with clear 400s).
+- **Double-coupling a locomotive** → `409 already coupled to train …` (guard works).
+- **`train-builder` yard change** correctly relocates the whole consist (locos + wagons follow).
+- **Dispatch / arrive / finalize** status guards, dispatch train-number pooling with pessimistic locks, route direction derivation and segment ordering, EAT-timezone math in `batch-window.util.ts` (no string date comparisons).
+- **Booking cancellation** can't leak reserved capacity (only pre-reservation statuses cancellable); approval-step sequencing; `allocateContainers` and interchange generation are properly transactional.
+- **GT06 GPS codec** coordinate/CRC/UTC decoding correct.
+- **notification-inbox WebSocket auth**, **signatures**, **backoffice role management** (blocks reserved roles), **forgot-password/customer-reset** (use the hardened OTP + single-use IAM tickets) — all scope correctly.
+- **Clearance-fee migration 2260** itself is sound (nullable/defaulted, `IF NOT EXISTS`); the gate enforcement on document upload is correct on both contract and booking sides (the bugs are around it — M26, and C1–C3 letting the fee be "paid" for free).
+
+---
+
+## 9. Boot-log noise (environment, not code bugs)
+
+- **SMS + Email over RabbitMQ fail:** `Handshake terminated by server: 403 (ACCESS-REFUSED) … Login was refused using authentication mechanism PLAIN` — broker credentials for the dev environment. (Compounds M21: email is doubly dead — stub strategy *and* no broker.)
+- **Swagger duplicate-DTO warnings:** `UpdateProfileDto`, `RequestChangesDto`, `SignContractDto` each defined twice with different schemas ("will throw in the next major version"). Rename the duplicates.
+- **`LegacyRouteConverter` warning** on `/api/*` — path-to-regexp v6 wants `/api/*path`.
+
+---
+
+## 10. Test-data side effects from this audit (please review)
+
+I drove real writes against `edr_freight`. Net state:
+
+1. **Created train `TR-00002`** (`exportTrainNumber 9901`, `importTrainNumber 9902`, name "QA-TEST-TRAIN") — then **deleted it** (cleaned up).
+2. **Destroyed wagon `BW1-0009`** (original UUID `905cad62-1b12-46a0-9268-bacbf115e787`) via the hard-delete bug (H3). **I recreated it via the API** — new UUID `d9b578ed-e65a-48f6-9ae2-fb8239451460`, `AVAILABLE`, in KALITY, same wagon type. The wagon count is whole again; only the UUID changed (its old `wagon_movements` ledger was cascade-deleted and cannot be recovered).
+3. **Locomotives briefly moved** during the yard-divergence repro (LOCO-004, LOCO-023, LOCO-025) — **all restored** to KALITY / their original yards. LOCO-004 was briefly set OUT_OF_SERVICE by the decommission test and **restored to AVAILABLE**.
+
+No other records were mutated. (A raw-SQL restore of the original wagon UUID was intentionally **not** performed — the DB-write guard blocked it, and re-inserting via the API is the correct, app-logic-respecting cleanup.)
+
+---
+
+## 11. Prioritized fix roadmap
+
+**Do first (security / money — a customer or attacker can exploit these today):**
+1. C1 — remove the DEMO auto-settle in `billing.service.ts:973`.
+2. C2 / H13 — auth-guard `mark-paid`, `payments/initiate`, `payments/checkout`, `payments/receipt`, and `files/:id`.
+3. C3 + C4 — unify currency units and enforce amount verification at settlement.
+4. C5 — enable Telebirr/D-Money webhook signature checks.
+5. C6 / H10 / H11 / H12 / M24 / M25 — add ownership/permission guards to booking writes, import-operations, interchange-documents, contract-sign, incidents, tracking.
+
+**Do next (data integrity — the consist family, one structural fix):**
+6. **H1–H7 + L1–L2**: the `TrainLocomotive`/`wagon.trainId` coupling guard on every locomotive/wagon mutation (this is your reported bug and its whole family). Switch `DELETE` to soft-delete.
+7. H8/H9 capacity & consolidation locks; H17 cancel-status guard; H18 transactional reschedule.
+
+**Then (correctness):** M1–M27 (container release, weight units, scheduling day/priority/notification bugs, OTP hardening, self-approval).
+
+**Finally (hardening):** the L-series + the frontend dirty-field / display fixes in §7.
+
+---
+
+*Report generated from a live run of `pnpm run dev:freight` plus a full read of the in-scope modules. Every "live-confirmed" item has a reproduced request/response above; every code finding cites `file:line`.*
+
+---
+
+# ADDENDUM — Fixes Applied (2026-07-16)
+
+All **18 High + 27 Medium** findings were fixed. Critical (C1–C6) and Low (L-series) were **left untouched** per the request (except L1, which shares the H1 guard). Applied across ~35 backend files + 3 frontend files via 6 partitioned edit passes.
+
+**Verification status**
+- `turbo run type-check --filter=@edr/freight-api` → **clean (0 errors)**.
+- Web apps: freight-portal clean; freight-backoffice fails **only** on the pre-existing `user-management/web-Management/**` errors (documented, none in edited files).
+- `scheduling-reschedule.service.spec` → **5/5 pass** (updated for the H18 transaction + a future-dated fixture for the new M7 past-date guard).
+- App **boots clean** on 3030; migration `2280000000000-WagonNumberPartialUnique` applied and recorded (partial unique index `UQ_wagons_wagon_number_active` verified live).
+
+**Live-verified fixes (real requests against the running app)**
+- **H1** — `PATCH /locomotives/:id` on a coupled loco → `409 "coupled to train TR-00002; move the train instead"` (yard) and `409 "detach it before changing its status"` (status). *This is your reported bug — now behaves exactly as requested.*
+- **L1** — decommission coupled loco → `409`.
+- **H2** — `PATCH /wagons/:id` yard on coupled wagon → `409`; sending `trainId` → `400 "property trainId should not exist"`.
+- **H3** — `DELETE /wagons/:id` on coupled wagon → `409`, wagon **survives** (data-loss bug closed); delete now uses `softRemove`.
+- **H4** — `DELETE /trains/:id` on a built train → frees wagon (AVAILABLE, unlinked) + both locos, **soft-deletes** the train (tombstone row, not stranded).
+
+**What each pass changed (high level)**
+- **Fleet (H1–H7, L1):** coupling guard (`train_locomotives` / `wagon.trainId`) on every legacy loco/wagon mutation; `assignToTrain` tightened to builder rules; `bulk-status`/`bulk-transfer`/`unassign` refuse coupled/pinned wagons; hard-delete → soft-delete (+ migration); frontend `AssignWagonDialog` `||`→`&&`, workspace modal excludes coupled wagons.
+- **Scheduling (H17, M8–M10, M14, M15):** cancel status-guard; null-date sort fixed; booking-day match check; partial-unassign now releases slots + recomputes TrainSet totals; route edit blocked while live schedules reference it; capacity meter uses weakest loco.
+- **Reschedule (H18, M7, M11–M13):** date+event wrapped in one transaction; `newDepartureDate > now` guard; real `MaintenanceRescheduleDto` (validation no longer skipped); merged incoming-booking set; retained-customer notice only when the date actually moves; displaced-fallback clears `trainScheduleId`.
+- **Bookings/Billing/Contracts (H8, H9, H12, H14–H16, M26, M27):** export-capacity + consolidation now lock the row (`FOR UPDATE`) and re-check before reserving; payment idempotency guard restored (won't resurrect CANCELLED/advanced); cancel/reject expire open invoices; contract-sign requires ownership + verifies OTP against the company's registered phone; pricing honors frozen contract-rate snapshots; clearance-fee gate hard-fails instead of waiving; invoice state-machine gaps closed.
+- **Cargo/Containers/Customs (M1–M6, H10, H11):** container release counts fixed (exclude-self); unload frees container; load rejects over-capacity; container→wagon assign rejects double-assign + wraps position in a txn; interchange state-machine guarded; interchange item weights normalized to tons; import-operations + interchange-documents controllers now permission-guarded.
+- **Ops/Auth/Files (H13, M16–M25):** file download requires auth; incidents + tracking guarded; maintenance flips vehicle status/availability; fuel rejects non-positive + duplicate receipts; OTP uses CSPRNG + TTL/attempt-cap/consume-on-success, send no longer resets the brute-force counter; email strategy implemented + failures surfaced; rate/priority self-approval blocked; driver uploads size/type-limited.
+
+**Residual / partial (flagged in code with TODOs — intentional, safe)**
+- **H18** cross-service (un)assign calls still run their own transactions (can't thread the manager without editing the scheduling core); date+event are atomic.
+- **M7** kept the raw date write + `>now` guard; booking-window fields are not re-derived (delegating to `updateScheduleDate` would wrongly require `PRE_WINDOW`).
+- **M11** best-effort detach (clears `trainScheduleId`); the link-row/allocation delete lives in the scheduling core.
+- **M13** format is enforced; `newDepartureDate` is validated-when-present but not strictly required (parent DTO marks it optional).
+- **M15** meter capped at weakest loco; consist tare isn't available client-side.
+- **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope).
+- **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet).
+- **M22** self-approval blocked; a distinct CEO/approver permission is recommended (TODO).
+- **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one).
+- **M25** view-guarded; company-scoping the query is a TODO.
+- **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`).
+- **H15** frozen rates cover base rail + surcharges + first/last-mile; a rare container-fallback line keeps the live rate.
+- **M3** position race narrowed by a txn; a `(wagon_id, position)` unique index is the full fix (TODO).
+
+**Not committed.** All changes are in the working tree only.
diff --git a/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts b/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts
new file mode 100644
index 000000000..f28c81317
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2280000000000-WagonNumberPartialUnique.ts
@@ -0,0 +1,70 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain
+ * UNIQUE on wagon_number would keep a retired wagon's number reserved forever
+ * and block ever re-registering that number. Swap it for a PARTIAL unique index
+ * that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no
+ * longer occupy their number.
+ *
+ * NOTE: the shared dev DB has no applied migration history, so this is also
+ * hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it
+ * idempotent whether the original uniqueness is the auto-named column
+ * constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index.
+ */
+export class WagonNumberPartialUnique2280000000000 implements MigrationInterface {
+ name = 'WagonNumberPartialUnique2280000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is
+ // named (dropping the constraint also drops its backing index).
+ await queryRunner.query(`
+ DO $$
+ DECLARE con_name text;
+ BEGIN
+ FOR con_name IN
+ SELECT conname
+ FROM pg_constraint
+ WHERE conrelid = 'freight.wagons'::regclass
+ AND contype = 'u'
+ AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%'
+ LOOP
+ EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name);
+ END LOOP;
+ END $$;
+ `);
+
+ // Drop any standalone (non-partial) unique index on wagon_number too.
+ await queryRunner.query(`
+ DO $$
+ DECLARE idx_name text;
+ BEGIN
+ FOR idx_name IN
+ SELECT c.relname
+ FROM pg_index i
+ JOIN pg_class c ON c.oid = i.indexrelid
+ WHERE i.indrelid = 'freight.wagons'::regclass
+ AND i.indisunique
+ AND i.indpred IS NULL
+ AND c.relname <> 'UQ_wagons_wagon_number_active'
+ AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%'
+ LOOP
+ EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name);
+ END LOOP;
+ END $$;
+ `);
+
+ // Live wagon numbers stay unique; soft-deleted rows are exempt.
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active"
+ ON freight.wagons (wagon_number)
+ WHERE deleted_at IS NULL;
+ `);
+ }
+
+ public async down(): Promise {
+ // No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted
+ // wagons share a number, and the partial index is strictly safer. Left in
+ // place intentionally.
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 3e104c7ed..8c9461018 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -602,6 +602,19 @@ export class BillingService {
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
+ // M27: a Draft invoice is not yet issued and an Expired invoice's pay
+ // window has closed — neither is payable. Without these guards a payment
+ // could settle an unissued draft or a lapsed invoice.
+ if (invoice.status === Freight.InvoiceStatus.Draft) {
+ throw new BadRequestException(
+ "Cannot pay a draft invoice — it must be issued first.",
+ );
+ }
+ if (invoice.status === Freight.InvoiceStatus.Expired) {
+ throw new BadRequestException(
+ "Cannot pay an expired invoice — its payment window has closed.",
+ );
+ }
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
@@ -891,6 +904,20 @@ export class BillingService {
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise {
+ // M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
+ // does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
+ // Draft → Pending/Issued issue transition. It must NEVER mark an invoice
+ // Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
+ // balance implications and must go through the dedicated settlement methods
+ // (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
+ if (
+ status !== Freight.InvoiceStatus.Pending &&
+ status !== Freight.InvoiceStatus.Issued
+ ) {
+ throw new BadRequestException(
+ `updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
+ );
+ }
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
diff --git a/apps/edr-freight-api/src/modules/billing/payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts
index 543c84201..f72ad8922 100644
--- a/apps/edr-freight-api/src/modules/billing/payment.controller.ts
+++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts
@@ -103,8 +103,35 @@ export class PaymentController {
}
}
+ /**
+ * HTML-escape a value interpolated into the public checkout pages. These
+ * pages are served unauthenticated and the interpolated values (provider
+ * error messages, status strings, intent ids, redirect URLs) can carry
+ * attacker-influenced input — unescaped they are a reflected-XSS sink.
+ */
+ private escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
private buildRedirectHtml(url: string): string {
- const escaped = url.replace(/\"/g, """);
+ // Only http(s) URLs may be used as a redirect target — a javascript:
+ // URL would execute in the victim's browser from the /location.href.
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return this.buildErrorHtml("Invalid payment redirect URL");
+ }
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return this.buildErrorHtml("Invalid payment redirect URL");
+ }
+ const escaped = this.escapeHtml(url);
+ const jsEscaped = JSON.stringify(url);
return `
@@ -126,12 +153,14 @@ export class PaymentController {
Redirecting to payment provider…
Click here if you are not redirected
-
+
`;
}
- private buildStatusHtml(status: string, intentId: string): string {
+ private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
+ const status = this.escapeHtml(rawStatus);
+ const intentId = this.escapeHtml(rawIntentId);
return `
@@ -153,7 +182,8 @@ export class PaymentController {
`;
}
- private buildErrorHtml(message: string): string {
+ private buildErrorHtml(rawMessage: string): string {
+ const message = this.escapeHtml(rawMessage);
return `
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
index ddf09dea6..b0a3ad766 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
@@ -119,6 +119,26 @@ export class BookingInvoiceService {
return this.billing.updateStatus(invoiceId, status, manager);
}
+ /**
+ * Expire the booking's currently-open prepaid invoice when the booking is
+ * cancelled or rejected — the counterpart to the pay-window-expiry path
+ * (which also calls {@link BillingService.expirePayable}). Stops a terminated
+ * booking from leaving a payable invoice open. No-op when the booking has no
+ * open invoice (never invoiced, already paid/cancelled/expired). Pass a
+ * caller `manager` to enlist in its transaction.
+ */
+ expireOpenInvoices(
+ bookingId: string,
+ manager?: EntityManager,
+ ): Promise {
+ return this.billing.expirePayable(
+ Freight.InvoiceSource.Booking,
+ bookingId,
+ "PREPAID",
+ manager,
+ );
+ }
+
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
@@ -138,7 +158,32 @@ export class BookingInvoiceService {
);
return;
}
- // if (booking.paymentStatus === "PAID") return;
+
+ // Idempotency + state-machine guard (restored). The prepaid-invoice paid
+ // event can be delivered more than once (retries / re-emit), and a booking
+ // may have moved on or been terminated between invoicing and settlement.
+ // Only advance one that is still awaiting payment: no-op when already PAID,
+ // and refuse to advance a booking in a terminal/advanced status
+ // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
+ // rewrite its status or re-run allocation.
+ if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
+ return;
+ }
+ const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
+ "CANCELLED",
+ "REJECTED",
+ "EXPIRED",
+ "IN_TRANSIT",
+ "ARRIVED",
+ "COMPLETED",
+ "CONTRACT_CLOSED",
+ ];
+ if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
+ this.logger.warn(
+ `Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
+ );
+ return;
+ }
await this.dataSource.transaction(async (mg) => {
await mg.update(
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index 58351c080..091232611 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
+import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
@@ -128,11 +129,18 @@ export class BookingPricingService {
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
+ // H15: a booking created under a contract prices from that contract's FROZEN
+ // rate snapshots (the agreed rates), not the live rate of the day. Loaded
+ // once and threaded through the line builders; each rate code that has a
+ // snapshot uses it, and any code without one falls back to the live rate.
+ // Non-contract bookings resolve to null and keep the live-rate path.
+ const frozenRates = await this.loadFrozenContractRates(booking);
+
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
- await this.computeBaseRailLinesWithRates(booking, evalInput);
+ await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -141,7 +149,7 @@ export class BookingPricingService {
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
- await this.computeFirstLastMileLines(booking, evalInput);
+ await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
@@ -153,15 +161,14 @@ export class BookingPricingService {
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
- const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
- const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
- // derive from total ÷ unit price.
+ // derive from total ÷ unit price (the live unit price — a count, not a
+ // currency amount, so it is snapshot-independent).
const quantity =
unit === 'FLAT' || unit === 'PER_INVOICE'
? 1
@@ -171,6 +178,26 @@ export class BookingPricingService {
? Math.max(1, Math.round(usdAmount / unitUsd))
: 1;
+ // H15: bill the frozen contract surcharge rate (already in the booking
+ // currency) when this code has a snapshot; else keep the live amount.
+ const frozen = this.frozenRateByCode(
+ frozenRates,
+ mod.surchargeCode,
+ paymentCurrency,
+ );
+ const unitAmount = frozen
+ ? Number(frozen.unitPrice)
+ : isEtbBooking
+ ? Math.round(unitUsd * usdToEtb)
+ : unitUsd;
+ const convertedAmount = frozen
+ ? isEtbBooking
+ ? Math.round(unitAmount * quantity)
+ : unitAmount * quantity
+ : isEtbBooking
+ ? Math.round(usdAmount * usdToEtb)
+ : usdAmount;
+
const item: PriceLineItemDto = {
code: mod.surchargeCode,
description: surchargeLabel(mod.surchargeCode),
@@ -424,6 +451,7 @@ export class BookingPricingService {
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
+ frozenRates: Map | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
@@ -453,15 +481,35 @@ export class BookingPricingService {
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
- const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
- const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(rate.rateValue);
+ // H15: frozen contract rate for this container size, when present — its
+ // unitPrice is already in the booking currency (no USD→currency convert).
+ const frozen = await this.frozenRateForContainer(
+ frozenRates,
+ container.containerTypeId,
+ paymentCurrency,
+ );
+ let amount: number;
+ let unitAmount: number;
+ if (frozen) {
+ unitAmount = Number(frozen.unitPrice);
+ amount = this.amountForUnit(
+ rate.rateUnit,
+ unitAmount,
+ container.quantity,
+ wagonCount,
+ );
+ } else {
+ const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
+ amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
+ unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
+ }
const label = await this.containerTypeLabel(container.containerTypeId);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
- unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
+ unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
@@ -477,14 +525,31 @@ export class BookingPricingService {
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
- const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
- const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(fallback.rateValue);
+ // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
+ const frozen = isBulk
+ ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
+ : null;
+ let amount: number;
+ let unitAmount: number;
+ if (frozen) {
+ unitAmount = Number(frozen.unitPrice);
+ amount = this.amountForUnit(
+ fallback.rateUnit,
+ unitAmount,
+ quantity,
+ wagonCount,
+ );
+ } else {
+ const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
+ amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
+ unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
+ }
lines.push({
code: rateType,
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
amount,
- unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
+ unitAmount,
unit: fallback.rateUnit,
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
@@ -508,6 +573,7 @@ export class BookingPricingService {
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
+ frozenRates: Map | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
@@ -565,18 +631,34 @@ export class BookingPricingService {
break;
}
- const usdAmount = value * quantity;
+ // H15: frozen mile rate (already in booking currency) when the contract
+ // has one; else the live USD rate converted as before.
+ const frozen = this.frozenRateByCode(
+ frozenRates,
+ leg.rateType,
+ paymentCurrency,
+ );
+ let amount: number;
+ let unitAmount: number;
+ if (frozen) {
+ unitAmount = Number(frozen.unitPrice);
+ amount = isEtbBooking
+ ? Math.round(unitAmount * quantity)
+ : unitAmount * quantity;
+ } else {
+ const usdAmount = value * quantity;
+ amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
+ unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
+ }
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
- if (!(usdAmount > 0)) continue;
+ if (!(amount > 0)) continue;
- const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
- const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
- unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
+ unitAmount,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
@@ -649,21 +731,93 @@ export class BookingPricingService {
}
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
- const value = Number(rate.rateValue);
- switch (rate.rateUnit) {
+ return this.amountForUnit(
+ rate.rateUnit,
+ Number(rate.rateValue),
+ quantity,
+ wagonCount,
+ );
+ }
+
+ /** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
+ private amountForUnit(
+ rateUnit: string,
+ unitValue: number,
+ quantity: number,
+ wagonCount: number,
+ ): number {
+ switch (rateUnit) {
case 'PER_CONTAINER':
- return value * quantity;
+ return unitValue * quantity;
case 'PER_WAGON':
- return value * wagonCount;
+ return unitValue * wagonCount;
case 'PER_TON':
- return value * quantity;
+ return unitValue * quantity;
case 'FLAT':
- return value;
+ return unitValue;
default:
- return value * quantity;
+ return unitValue * quantity;
}
}
+ // ── H15: frozen contract rate snapshots ────────────────────────────────────
+
+ /**
+ * Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
+ * for a non-contract booking (or a contract with no snapshots). The pricing
+ * line builders prefer a matching snapshot's unit price over the live rate.
+ */
+ private async loadFrozenContractRates(
+ booking: Booking,
+ ): Promise | null> {
+ if (!booking.contractId) return null;
+ const snapshots = await this.bookingsRepository.findContractRateSnapshots(
+ booking.contractId,
+ );
+ if (!snapshots.length) return null;
+ const byCode = new Map();
+ for (const snap of snapshots) byCode.set(snap.rateCode, snap);
+ return byCode;
+ }
+
+ /**
+ * The frozen snapshot for a rate code, or null when there is none, its price
+ * is negative, or it is in a different currency than the booking (in which
+ * case the live-rate path is safer than a mis-converted frozen price).
+ */
+ private frozenRateByCode(
+ frozenRates: Map | null,
+ code: string,
+ bookingCurrency: string,
+ ): ContractRateSnapshot | null {
+ const snap = frozenRates?.get(code);
+ if (!snap) return null;
+ if (snap.currency !== bookingCurrency) return null;
+ if (!(Number(snap.unitPrice) >= 0)) return null;
+ return snap;
+ }
+
+ /**
+ * The frozen base-rail snapshot for a container line, matched by the
+ * container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
+ * ContractPricingService freezes). Null when there is no snapshot.
+ */
+ private async frozenRateForContainer(
+ frozenRates: Map | null,
+ containerTypeId: string,
+ bookingCurrency: string,
+ ): Promise {
+ if (!frozenRates) return null;
+ let sizeFt: number | null = null;
+ try {
+ sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
+ } catch {
+ return null;
+ }
+ if (!sizeFt) return null;
+ return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
+ }
+
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]
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 581ad917c..69d505c3b 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
@@ -534,6 +534,10 @@ export class BookingTransitionService {
"REJECTION",
);
+ // Stop the open-invoice leak: a cancelled booking must not leave a payable
+ // invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
+ await this.invoiceService.expireOpenInvoices(bookingId);
+
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
@@ -562,6 +566,10 @@ export class BookingTransitionService {
"REJECTION",
);
+ // Stop the open-invoice leak: a rejected booking must not leave a payable
+ // invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
+ await this.invoiceService.expireOpenInvoices(bookingId);
+
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index 317066cc4..72211925f 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
+import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository {
return Number(route?.km ?? 0);
}
+ /**
+ * Frozen contract unit-rate snapshots for a contract (H15). A booking created
+ * under a contract prices from these agreed, frozen rates rather than the live
+ * rate of the day; the pricing service matches them by rate code.
+ */
+ findContractRateSnapshots(
+ contractId: string,
+ ): Promise {
+ return this.dataSource
+ .getRepository(ContractRateSnapshot)
+ .find({ where: { contractId } });
+ }
+
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
@@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository {
quantity: number;
containersPerWagon: number;
},
+ manager?: EntityManager,
): Promise {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
- const qb = this.repository
+ const repo = manager ? manager.getRepository(Booking) : this.repository;
+ const qb = repo
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
@@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository {
);
}
- return qb.orderBy('b.createdAt', 'ASC').getOne();
+ qb.orderBy('b.createdAt', 'ASC');
+
+ // H9: under the caller's transaction, take a write lock on the matched
+ // partner booking row (FOR UPDATE OF b — booking rows only, not the joined
+ // reference tables) so a concurrent consolidation cannot claim the same
+ // partner between this find and the pair write. Only when a transaction
+ // manager is supplied — a pessimistic lock requires an open transaction.
+ if (manager) {
+ qb.setLock('pessimistic_write', undefined, ['b']);
+ }
+
+ return qb.getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */
@@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository {
quantity: number;
containersPerWagon: number;
}>,
+ manager?: EntityManager,
): Promise {
for (const slot of slots) {
- const partner = await this.findComplementaryConsolidationPartner(booking, slot);
+ const partner = await this.findComplementaryConsolidationPartner(
+ booking,
+ slot,
+ manager,
+ );
if (partner) return partner;
}
return null;
@@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository {
} as never);
}
+ /**
+ * Race-safe pairing (H9): the transactional counterpart of
+ * {@link pairConsolidation}. Must run inside the caller's transaction
+ * (`manager`), which should already hold the partner-row write lock taken by
+ * {@link findComplementaryConsolidationPartner}. Re-reads both rows and
+ * re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
+ * `false` (no write) when either booking was already paired by a concurrent
+ * flow, so the caller can fall back to parking.
+ */
+ async pairConsolidationIfUnpaired(
+ bookingId: string,
+ partnerId: string,
+ manager: EntityManager,
+ ): Promise {
+ const repo = manager.getRepository(Booking);
+ // Sequential (one connection per transaction) — never Promise.all here.
+ const booking = await repo.findOne({
+ where: { id: bookingId },
+ select: {
+ id: true,
+ consolidationPartnerId: true,
+ consolidationResumeStatus: true,
+ },
+ });
+ const partner = await repo.findOne({
+ where: { id: partnerId },
+ select: {
+ id: true,
+ consolidationPartnerId: true,
+ consolidationResumeStatus: true,
+ },
+ });
+
+ // Re-assert both are still unpaired before writing (the partner row is held
+ // under the finder's write lock, so its state is stable here).
+ if (
+ !booking ||
+ !partner ||
+ booking.consolidationPartnerId != null ||
+ partner.consolidationPartnerId != null
+ ) {
+ return false;
+ }
+
+ await repo.update(bookingId, {
+ consolidationPartnerId: partnerId,
+ status: booking.consolidationResumeStatus ?? 'SUBMITTED',
+ consolidationResumeStatus: null,
+ } as never);
+ await repo.update(partnerId, {
+ consolidationPartnerId: bookingId,
+ status: partner.consolidationResumeStatus ?? 'SUBMITTED',
+ consolidationResumeStatus: null,
+ } as never);
+ return true;
+ }
+
/**
* Park a booking that needs consolidation but has no partner yet. The optional
* resumeStatus is where the booking returns once it pairs — pass it for a
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 4aade3bf2..588e4f1ed 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -508,13 +508,28 @@ export class BookingsService {
return { booking, messages };
}
- const partner = await this.bookingsRepository.findConsolidationPartner(
- booking,
- slots,
- );
+ // H9: find + pair must be atomic. Run both inside one transaction where the
+ // finder holds a write lock on the candidate partner row and pairing
+ // re-asserts both rows are still unpaired before writing — otherwise two
+ // concurrent bookings can claim the same partner (or pair an
+ // already-paired booking). `didPair` is false when a concurrent flow won
+ // the partner, in which case we fall through to parking below.
+ const partner = await this.dataSource.transaction(async (manager) => {
+ const candidate = await this.bookingsRepository.findConsolidationPartner(
+ booking,
+ slots,
+ manager,
+ );
+ if (!candidate) return null;
+ const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
+ booking.id,
+ candidate.id,
+ manager,
+ );
+ return didPair ? candidate : null;
+ });
if (partner) {
- await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const paired = await this.findById(booking.id);
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
index 6f73035b4..54e47abaf 100644
--- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
+++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
@@ -1,6 +1,6 @@
-import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
+import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -32,6 +32,7 @@ export class CargoesService {
if (!container) {
throw new NotFoundException(`Container ${dto.containerId} not found`);
}
+ await this.assertContainerCapacity(container, dto.weight);
if (dto.cargoTypeId) {
const cargoType = await this.cargoTypeRepo.findOne({
@@ -121,6 +122,10 @@ export class CargoesService {
throw new ConflictException('Cargo already loaded or delivered');
}
+ if (cargo.container) {
+ await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
+ }
+
cargo.status = 'LOADED';
cargo.loadedAt = new Date();
cargo.quantity = dto.quantity;
@@ -137,13 +142,31 @@ export class CargoesService {
}
async unloadCargo(id: string): Promise {
- const cargo = await this.findById(id);
+ const cargo = await this.cargoRepo.findOne({
+ where: { id },
+ relations: { container: true },
+ });
+ if (!cargo) throw new NotFoundException('Cargo not found');
if (cargo.status !== 'LOADED') {
throw new ConflictException('Cargo is not loaded');
}
cargo.status = 'UNLOADED';
cargo.unloadedAt = new Date();
- return this.cargoRepo.save(cargo);
+ const saved = await this.cargoRepo.save(cargo);
+
+ // loadCargo flips the container to LOADED; on unload, free it back to
+ // AVAILABLE once no other LOADED cargo still references the container.
+ if (cargo.containerId != null && cargo.container) {
+ const remaining = await this.cargoRepo.count({
+ where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
+ });
+ if (remaining === 0) {
+ cargo.container.status = 'AVAILABLE';
+ await this.containerRepo.save(cargo.container);
+ }
+ }
+
+ return saved;
}
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise {
@@ -161,10 +184,13 @@ export class CargoesService {
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
+ // Exclude the cargo being delivered — it is still LOADED in the DB until the
+ // save below, so counting it would keep `remaining` > 0 and never free the
+ // container.
const remaining =
cargo.containerId != null
? await this.cargoRepo.count({
- where: { containerId: cargo.containerId, status: 'LOADED' },
+ where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
})
: 0;
if (remaining === 0 && cargo.container) {
@@ -174,4 +200,34 @@ export class CargoesService {
return this.cargoRepo.save(cargo);
}
+
+ /**
+ * Reject when placing `newWeightKg` on the container would exceed its max gross
+ * weight. All values are kilograms: cargoes.weight is kg (entity), and the
+ * container's tare_weight / max_gross_weight are kg (entity). Capacity check is
+ * tare + already-LOADED cargo + new cargo <= max gross weight.
+ */
+ private async assertContainerCapacity(
+ container: Container,
+ newWeightKg: number,
+ excludeCargoId?: string,
+ ): Promise {
+ const qb = this.cargoRepo
+ .createQueryBuilder('c')
+ .select('COALESCE(SUM(c.weight), 0)', 'sum')
+ .where('c.containerId = :containerId', { containerId: container.id })
+ .andWhere('c.status = :status', { status: 'LOADED' });
+ if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
+ const raw = await qb.getRawOne<{ sum: string }>();
+
+ const loadedKg = Number(raw?.sum ?? 0);
+ const tareKg = Number(container.tareWeight);
+ const maxGrossKg = Number(container.maxGrossWeight);
+ if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
+ throw new BadRequestException(
+ `Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
+ `new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
+ );
+ }
+ }
}
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts
index bf7c966aa..2f9d984e6 100644
--- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts
+++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts
@@ -1,7 +1,7 @@
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
+import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -18,6 +18,7 @@ export class ContainersService {
private readonly wagonRepo: Repository, // ✅ use raw repository
@InjectRepository(ContainerType)
private readonly containerTypeRepo: Repository,
+ private readonly dataSource: DataSource,
) {}
async create(dto: CreateContainerDto): Promise {
@@ -115,24 +116,42 @@ export class ContainersService {
if (container.status === 'LOADED') {
throw new ConflictException('Cannot reassign a loaded container');
}
+ // Reject a container that is already placed on a wagon — it must be
+ // unassigned first, otherwise it would silently jump to another wagon.
+ if (container.wagonId) {
+ throw new ConflictException(
+ `Container ${containerId} is already assigned to wagon ${container.wagonId}`,
+ );
+ }
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
- let position: number | null = dto.position ?? null;
- if (position === null) {
- const maxPos = await this.containerRepo
- .createQueryBuilder('c')
- .select('MAX(c.position)', 'max')
- .where('c.wagonId = :wagonId', { wagonId: wagon.id })
- .getRawOne();
- position = (maxPos?.max ?? 0) + 1;
- }
+ // The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
+ // read the same MAX and collide on the same position. Do the read + save inside
+ // one transaction to narrow the race window.
+ // TODO: add a unique (wagon_id, position) DB index so the database itself
+ // rejects a colliding position even under concurrency.
+ return this.dataSource.transaction(async (manager) => {
+ const containerRepo = manager.getRepository(Container);
- container.wagonId = wagon.id;
- container.position = position;
- container.status = 'AVAILABLE';
- return this.containerRepo.save(container);
+ let position: number | null = dto.position ?? null;
+ if (position === null) {
+ const maxPos = await containerRepo
+ .createQueryBuilder('c')
+ .select('MAX(c.position)', 'max')
+ .where('c.wagonId = :wagonId', { wagonId: wagon.id })
+ .getRawOne<{ max: number | null }>();
+ position = (maxPos?.max ?? 0) + 1;
+ }
+
+ container.wagonId = wagon.id;
+ container.position = position;
+ // Placing a container on a wagon does not make it AVAILABLE. The status enum
+ // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
+ // state, so leave the existing status unchanged rather than forcing AVAILABLE.
+ return containerRepo.save(container);
+ });
}
async unassignFromWagon(containerId: string): Promise {
diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
index 3d5dc617b..43ab9d22a 100644
--- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts
@@ -78,12 +78,21 @@ export class ClearanceFeeService {
* the pre-fee flow instead of dead-ending.
*/
async gateApplies(contract: Contract): Promise {
- if (!contract.customsClearingEnabled || !contract.companyId) return false;
- if ((await this.feeAmountOrNull(contract)) !== null) return true;
- this.logger.warn(
- `Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
- );
- return false;
+ // Customs disabled → the prepay gate genuinely does not apply.
+ if (!contract.customsClearingEnabled) return false;
+ // No company to bill (government / unlinked) → the gate cannot raise an
+ // invoice, so it stays out of the flow (same rule the booking invoice uses).
+ if (!contract.companyId) return false;
+ // M26: customs IS enabled and billable. A missing frozen fee line must NOT
+ // silently waive the gate — that ships clearance for free. Hard-fail exactly
+ // as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
+ // missing fee blocks counter-sign / shipment instead of bypassing payment.
+ if ((await this.feeAmountOrNull(contract)) === null) {
+ throw new UnprocessableEntityException(
+ 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
+ );
+ }
+ return true;
}
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
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 5fb935e74..e258820d7 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
@@ -793,17 +793,34 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
if (dto.role === 'CUSTOMER') {
+ // H12(a): only the owning company's customer may sign — assert ownership
+ // before anything else (hidden as NotFound otherwise). A signing customer
+ // has no permission key, so this is the gate that binds the sign to the
+ // contract's company.
+ await this.contractsService.assertCustomerCanAccessContract(
+ options.signerUserId,
+ contract,
+ );
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
- // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
- // must be verified before the signature is applied.
- if (!dto.otpPhone || !dto.otp) {
+ // Sudo-mode gate: a fresh, single-use OTP must be verified before the
+ // signature is applied. H12(b): verify against the CONTRACT COMPANY's
+ // registered phone — never the caller-supplied dto.otpPhone, which an
+ // attacker could point at their own phone to sign someone else's
+ // contract. The OTP is issued to the company's registered number.
+ const companyPhone = contract.company?.phone?.trim();
+ if (!companyPhone) {
+ throw new BadRequestException(
+ 'The contract company has no registered phone on file to verify the signing OTP against',
+ );
+ }
+ if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
- await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
+ await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
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 2957124f8..6284cad34 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -524,12 +524,18 @@ export class ContractsController {
@Post(':id/renew')
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
- renew(
+ async renew(
@Param('id', ParseUUIDPipe) id: string,
@Body() _dto: RenewContractDto,
- @CurrentUser() user: AuthUserPayload,
+ @CurrentUser() user: TCurrentUser,
) {
- return this.transitionService.renew(id, user?.id ?? user?.sub);
+ // H12(c): a customer may only renew a contract their company owns. Staff
+ // with bookings.view bypass, mirroring getContractView/downloadContractDocument.
+ const contract = await this.contractsService.findById(id);
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
+ }
+ return this.transitionService.renew(id, resolveAuthUserId(user));
}
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@@ -544,10 +550,17 @@ export class ContractsController {
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
- uploadClearanceDocuments(
+ async uploadClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: TCurrentUser,
@UploadedFiles() files: Express.Multer.File[],
) {
+ // H12(c): only the owning company's customer may upload clearance docs.
+ // Staff with bookings.view bypass, mirroring the other contract handlers.
+ const contract = await this.contractsService.findById(id);
+ if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
+ await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
+ }
return this.clearanceService.uploadDocuments(id, files ?? []);
}
diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts
index b20b99575..688e0b4c7 100644
--- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts
+++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts
@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
-const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
+// Canonical UPPERCASE — everything downstream (booking gating, pricing
+// surcharge, GL/portal booking forms) compares contract.equipmentReturn
+// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
+const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
export {
CONTRACT_KINDS,
@@ -161,6 +164,9 @@ export class CreateContractDto {
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
@IsOptional()
+ @Transform(({ value }) =>
+ typeof value === 'string' ? value.toUpperCase() : value,
+ )
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn?: string;
diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts
index d86ee823a..e5b6ae146 100644
--- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts
+++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Controller,
Get,
Post,
@@ -72,7 +73,30 @@ export class DriversController {
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
- @UseInterceptors(AnyFilesInterceptor())
+ // Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
+ // AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
+ @UseInterceptors(
+ AnyFilesInterceptor({
+ limits: { fileSize: 10 * 1024 * 1024, files: 20 },
+ fileFilter: (_req, file, cb) => {
+ const allowed = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif',
+ 'application/pdf',
+ ];
+ if (allowed.includes(file.mimetype)) {
+ cb(null, true);
+ } else {
+ cb(
+ new BadRequestException(`Unsupported file type: ${file.mimetype}`),
+ false,
+ );
+ }
+ },
+ }),
+ )
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts
index e0d876176..307e24985 100644
--- a/apps/edr-freight-api/src/modules/files/files.controller.ts
+++ b/apps/edr-freight-api/src/modules/files/files.controller.ts
@@ -6,22 +6,24 @@ import {
Query,
Res,
} from "@nestjs/common";
-import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
-import { Public } from "@edr/api-common";
+import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { FilesService } from "./files.service";
@ApiTags("files")
+@ApiBearerAuth()
@Controller("files")
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
- // Public so the browser can load the bytes directly via /
- {b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
-
-
- Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
-
-
forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
- disabled={forceConfirmMutation.isPending}
- >
- {forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
-
- {forceConfirmMutation.isError && (
-
- {(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
-
- )}
-
- )}
{/* Seats / Passengers */}
@@ -517,7 +510,9 @@ function BookingsPageContent() {
{isSeats && (
-
{p.seat?.seatNumber || p.seatId || '—'}
+
+ {[p.seat?.coach?.number || p.coach ? `Coach ${p.seat?.coach?.number || p.coach}` : null, p.seat?.seatNumber || p.seatNumber ? `Seat ${p.seat?.seatNumber || p.seatNumber}` : (p.seatId ? `Seat ${p.seatId.slice(0, 8)}` : '—')].filter(Boolean).join(' · ')}
+
{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}
)}
@@ -564,7 +559,7 @@ function BookingsPageContent() {
{/* Generate Ticket Modal */}
{ setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
+ onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
title="Generate Ticket"
size="md"
>
@@ -625,16 +620,31 @@ function BookingsPageContent() {
/>
- {forceConfirmMutation.isError && (
-
- {(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
-
- )}
+ {(forceConfirmMutation.isError || smartAssignMutation.isError) && (() => {
+ const e = (forceConfirmMutation.error ?? smartAssignMutation.error) as any;
+ const m = e?.response?.data?.message;
+ const msg = Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment';
+ const isConflict = e?.response?.status === 409 || msg?.toLowerCase().includes('seat');
+ const isFullyBooked = msg?.toLowerCase().includes('no available seats');
+ return (
+
+
+ {isFullyBooked ? '🚫 Schedule Fully Booked' : isConflict ? '⚠️ Seat Conflict Detected' : 'Error'}
+
+
{msg}
+
+ );
+ })()}
+
+
+
Seat auto-assignment
+
The system will automatically assign the best available seat and generate the ticket upon confirmation.
+
{ setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
+ onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
>
Cancel
@@ -642,18 +652,12 @@ function BookingsPageContent() {
onClick={() => {
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
- forceConfirmMutation.mutate({
- bookingId: generateTicketBooking.id,
- data: {
- paymentReference: generateTicketForm.paymentReference,
- paymentMethod: generateTicketForm.paymentMethod,
- notes: generateTicketForm.notes || undefined,
- },
- });
+ forceConfirmMutation.reset();
+ smartAssignMutation.mutate(generateTicketBooking.id);
}}
- disabled={forceConfirmMutation.isPending}
+ disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
>
- {forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
+ {(forceConfirmMutation.isPending || smartAssignMutation.isPending) ? 'Generating…' : 'Confirm & Generate Ticket'}
diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
index d00b18763..bc657106d 100644
--- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
@@ -3,62 +3,103 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
-import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
-import StatCard from '@/components/dashboard/StatCard';
-import DataTable from '@/components/ui/DataTable';
-import Badge from '@/components/ui/Badge';
+import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
-import { formatCurrency, formatDateTime } from '@/lib/utils';
+import { apiClient } from '@/lib/api-client';
+import { formatCurrency } from '@/lib/utils';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
+import Link from 'next/link';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
-// Mock data for fallback when API fails
-const MOCK_STATS = {
- totalBookings: 1247,
- totalRevenue: 892450,
- totalPassengers: 2156,
-};
+function StatCard({
+ icon, iconBg, label, total, loading, rows, href,
+}: {
+ icon: React.ReactNode;
+ iconBg: string;
+ label: string;
+ total: number;
+ loading: boolean;
+ rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
+ href: string;
+}) {
+ return (
+
+
+
+ {loading ? '—' : total.toLocaleString()}
+
+
+ {rows.map((r) => (
+
+ {r.icon}{r.label}
+
+ {loading ? '—' : r.value.toLocaleString()}
+
+
+ ))}
+
+
+ View all
+
+
+ );
+}
-const MOCK_RECENT_BOOKINGS = [
- {
- id: '1',
- bookingRef: 'BK-2024-001',
- passenger: { fullName: 'John Doe' },
- totalMinor: 125000,
- currency: 'ETB',
- status: 'CONFIRMED',
- createdAt: new Date().toISOString()
- },
- {
- id: '2',
- bookingRef: 'BK-2024-002',
- passenger: { fullName: 'Jane Smith' },
- totalMinor: 85000,
- currency: 'ETB',
- status: 'PENDING',
- createdAt: new Date().toISOString()
- }
-];
+function RevenueSection({
+ label, bookingCount, rows, subtotal, loading, renderRow,
+}: {
+ label: React.ReactNode;
+ bookingCount: number;
+ rows: { currency: string; totalMinor: number }[];
+ subtotal: number;
+ loading: boolean;
+ renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {loading ? '—' : bookingCount.toLocaleString()} bookings
+
+
+ {rows.length === 0
+ ?
No revenue yet
+ : rows.map(renderRow)}
+ {rows.length > 0 && (
+
+ Subtotal
+ {formatCurrency(subtotal, 'ETB')}
+
+ )}
+
+ );
+}
function DashboardPageContent() {
+ const { data: exchangeRates = [] } = useQuery
({
+ queryKey: ['currencies'],
+ queryFn: () => apiClient.get('/currencies'),
+ select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
+ });
+
+ const toEtbRate = (currency: string): number | null => {
+ if (currency === 'ETB') return 1;
+ const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
+ return r ? 1 / r.rate : null;
+ };
+
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
- queryKey: ['dashboard-stats'],
- queryFn: dashboardApi.getStats,
- retry: 1,
- staleTime: 60000, // 1 minute
- });
-
- const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery({
- queryKey: ['recent-bookings'],
- queryFn: () => dashboardApi.getRecentBookings(10),
- retry: 1,
- });
-
- const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
- queryKey: ['upcoming-trips'],
- queryFn: () => dashboardApi.getUpcomingTrips(5),
+ queryKey: ['backoffice-stats'],
+ queryFn: dashboardApi.getBackofficeStats,
retry: 1,
+ staleTime: 60000,
});
const { data: paymentMethods } = useQuery({
@@ -67,57 +108,38 @@ function DashboardPageContent() {
retry: 1,
});
- // Use actual data or fallback to mock/empty states
- const displayStats = stats || (statsError ? MOCK_STATS : null);
- const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData :
- (bookingsError ? MOCK_RECENT_BOOKINGS : []);
+ const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
+ rows.reduce((sum, { currency, totalMinor }) => {
+ const rate = toEtbRate(currency);
+ return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
+ }, 0);
- const bookingColumns = [
- { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
- {
- key: 'passenger',
- label: 'Passenger',
- render: (item: any) => {
- if (item.passenger?.fullName) {
- return item.passenger.fullName;
- }
- if (item.contactEmail) {
- return item.contactEmail;
- }
- if (item.contactPhone) {
- return item.contactPhone;
- }
- return 'N/A';
- }
- },
- { key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
- {
- key: 'status',
- label: 'Status',
- render: (item: any) => (
-
- {item.status}
-
- )
- },
- { key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
- ];
+ const normalRows = stats?.revenueByCurrency ?? [];
+ const packageRows = stats?.packageRevenueByCurrency ?? [];
+ const normalGrand = calcGrand(normalRows);
+ const packageGrand = calcGrand(packageRows);
+ const overallGrand = normalGrand + packageGrand;
- const tripColumns = [
- { key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
- { key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name} → ${item.destinationStation?.name || item.destination?.name}` },
- { key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
- { key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
- {
- key: 'status',
- label: 'Status',
- render: (item: any) => (
-
- {item.status}
-
- )
- },
- ];
+ const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
+ const rate = toEtbRate(currency);
+ const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
+ return (
+
+
+
+ {currency}
+
+
+ {formatCurrency(totalMinor, currency)}
+ {currency !== 'ETB' && etbMinor !== null && (
+
+ ({formatCurrency(etbMinor, 'ETB')})
+
+ )}
+
+
+ );
+ };
return (
@@ -126,43 +148,105 @@ function DashboardPageContent() {
Welcome back! Here's your operational summary.
- {/* Error Alert */}
- {(statsError || bookingsError) && (
+ {statsError && (
-
- Some data may be outdated
-
-
- Unable to fetch live data. Showing cached or sample information.
-
+
Some data may be outdated
+
Unable to fetch live data. Showing cached or sample information.
)}
- {/* Primary Metrics */}
-
+ {/* Stat cards */}
+
}
+ iconBg="bg-blue-100 dark:bg-blue-900/30"
+ label="Bookings"
+ total={stats?.totalBookings ?? 0}
+ loading={statsLoading}
+ href="/bookings"
+ rows={[
+ { label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
+ { label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
+ ]}
/>
-
}
+ iconBg="bg-emerald-100 dark:bg-emerald-900/30"
+ label="Tickets"
+ total={stats?.totalTickets ?? 0}
+ loading={statsLoading}
+ href="/tickets"
+ rows={[
+ { label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
+ { label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
+ ]}
/>
+
+ {/* Revenue card */}
+
+
+ {statsLoading ? (
+
Loading…
+ ) : (
+ <>
+
+ {formatCurrency(overallGrand, 'ETB')}
+
+
+
+
Regular
+
{formatCurrency(normalGrand, 'ETB')}
+
+
+
Package
+
{formatCurrency(packageGrand, 'ETB')}
+
+
+
+ View payments
+
+ >
+ )}
+
+
+
+ {/* Revenue breakdown */}
+
+
Revenue Breakdown
+ {statsLoading ? (
+
Loading…
+ ) : !normalRows.length && !packageRows.length ? (
+
No revenue data yet.
+ ) : (
+
+
+
+
+ )}
{/* Payment Methods Distribution */}
@@ -171,16 +255,8 @@ function DashboardPageContent() {
Payment Methods Distribution
-
- {paymentMethods.map((entry, index) => (
+
+ {paymentMethods.map((_: any, index: number) => (
|
))}
@@ -189,35 +265,6 @@ function DashboardPageContent() {
)}
-
- {/* Recent Bookings */}
-
-
-
- Recent Bookings
-
-
-
-
- {/* Upcoming Trips */}
-
-
-
- Upcoming Trips
-
-
-
-
);
}
@@ -228,4 +275,4 @@ export default function DashboardPage() {
);
-}
\ No newline at end of file
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx
index 3c13e952b..e49c16523 100644
--- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx
@@ -7,7 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
-import { excessBaggageApi } from '@/lib/api';
+import { excessBaggageApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -34,6 +34,15 @@ export default function ExcessBaggagePage() {
const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState(null);
+ const { data: allowancesData } = useQuery({
+ queryKey: ['baggage-allowances'],
+ queryFn: () => apiClient.get('/agents/excess-baggage/allowances'),
+ });
+ const allowances: any[] = Array.isArray(allowancesData)
+ ? allowancesData
+ : (allowancesData as any)?.items ?? (allowancesData as any)?.data ?? [];
+ const excessRate = allowances[0] ?? null;
+
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
queryFn: () => excessBaggageApi.getAll({
@@ -229,58 +238,76 @@ export default function ExcessBaggagePage() {
Logging as agent: {user.fullName}
)}
-
- Booking ID
- setLogForm({ ...logForm, bookingId: e.target.value })}
- />
-
-
- Excess Weight (kg)
- setLogForm({ ...logForm, excessWeightKg: e.target.value })}
- />
-
-
- setLogForm({ ...logForm, collectCash: e.target.checked })}
- />
- Collect cash now (no payment link sent)
-
- {!logForm.collectCash && (
-
- A payment link will be sent to the passenger's email and phone on file.
-
+ {!excessRate ? (
+
+ No excess luggage rate configured. Please set a rate in Tariff Rates before logging.
+
+ ) : (
+ <>
+
+ Rate: {(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg
+
+
+ Booking ID
+ setLogForm({ ...logForm, bookingId: e.target.value })}
+ />
+
+
+ Excess Weight (kg)
+ setLogForm({ ...logForm, excessWeightKg: e.target.value })}
+ />
+
+ {logForm.excessWeightKg && (
+
+ Estimated charge: {((excessRate.excessFeePerKg / 100) * parseInt(logForm.excessWeightKg || '0')).toFixed(2)} ETB
+
+ )}
+
+ setLogForm({ ...logForm, collectCash: e.target.checked })}
+ />
+ Collect cash now (no payment link sent)
+
+ {!logForm.collectCash && (
+
+ A payment link will be sent to the passenger's email and phone on file.
+
+ )}
+ >
)}
{logError && {logError}
}
setLogModal(false)}>Cancel
-
{
- if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
- setLogError('Booking ID and excess weight are required');
- return;
- }
- logMutation.mutate({
- bookingId: logForm.bookingId.trim(),
- excessWeightKg: parseInt(logForm.excessWeightKg),
- collectCash: logForm.collectCash,
- });
- }}
- >
- {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
-
+ {excessRate && (
+
{
+ if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
+ setLogError('Booking ID and excess weight are required');
+ return;
+ }
+ logMutation.mutate({
+ bookingId: logForm.bookingId.trim(),
+ excessWeightKg: parseInt(logForm.excessWeightKg),
+ collectCash: logForm.collectCash,
+ });
+ }}
+ >
+ {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
index 9c9ee3286..f849654b5 100644
--- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
@@ -663,10 +663,6 @@ export default function SeatsPage() {
Blocked
-
Removed
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
index 5723197c9..49c0ec3f4 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx
@@ -26,20 +26,19 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
const handleSave = async () => {
setError(null);
- if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) {
- setError('All fields are required'); return;
+ if (!form.excessFeePerKg) {
+ setError('Excess fee per kg is required'); return;
}
- const payload = {
- seatClassId: form.seatClassId,
- maxWeightKg: parseInt(form.maxWeightKg),
- maxPiecesCount: parseInt(form.maxPiecesCount),
- excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100),
- };
+ const feeMinor = Math.round(parseFloat(form.excessFeePerKg) * 100);
try {
if (editing) {
- await update.mutateAsync({ id: editing.id, ...payload });
+ await update.mutateAsync({ id: editing.id, excessFeePerKg: feeMinor });
} else {
- await create.mutateAsync(payload);
+ // Create a rule for every seat class that doesn't already have one
+ const existingClassIds = new Set(allowances.map((a: BaggageAllowance) => a.seatClassId));
+ const missing = allClasses.filter(sc => !existingClassIds.has(sc.id));
+ if (!missing.length) { setError('All seat classes already have a rule. Use Edit to update.'); return; }
+ await Promise.all(missing.map(sc => create.mutateAsync({ seatClassId: sc.id, excessFeePerKg: feeMinor })));
}
resetForm();
onClose();
@@ -58,9 +57,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
{a.seatClass?.name ?? a.seatClassId} },
- { key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs },
- { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => {(a.excessFeePerKg / 100).toFixed(2)} ETB },
+ { key: 'excessFeePerKg', label: 'Fare per kg (ETB)', render: (a: BaggageAllowance) => {(a.excessFeePerKg / 100).toFixed(2)} ETB },
]}
actions={[
{
@@ -74,36 +71,18 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
]}
loading={false}
- emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.'
+ emptyMessage='No excess luggage tariff rates defined. Click "Add Luggage Rate" to create one.'
/>
)}
{ resetForm(); onClose(); }}
- title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
+ title={editing ? 'Edit Excess Luggage Rate' : 'Add Excess Luggage Rate'}
size="md"
>
{error &&
{error}
}
-
-
Seat Class *
-
setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
- Select seat class...
- {allClasses.map(sc => {sc.name} )}
-
- {editing &&
Seat class cannot be changed. Delete and recreate to change.
}
-
-
Excess Fee per kg (ETB) *
setForm({ ...form, excessFeePerKg: e.target.value })} />
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts
index 5035033c0..fe2473d0a 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts
@@ -31,4 +31,7 @@ export const bookingsApi = {
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
+
+ smartAssign: (bookingId: string) =>
+ apiClient.post(`/tickets/smart-assign/${bookingId}`, {}),
};
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
index cfd1c681e..2a7a7f767 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
@@ -2,6 +2,21 @@ import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
+ getBackofficeStats: async () => {
+ const response = await apiClient.get<{
+ totalBookings: number;
+ totalNormalBookings: number;
+ totalPackageBookings: number;
+ totalTickets: number;
+ totalNormalTickets: number;
+ totalPackageTickets: number;
+ totalPassengers: number;
+ revenueByCurrency: { currency: string; totalMinor: number }[];
+ packageRevenueByCurrency: { currency: string; totalMinor: number }[];
+ }>('/dashboard/backoffice-stats');
+ return response;
+ },
+
getStats: async () => {
try {
// Fetch bookings and passengers data in parallel
diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
index 28ede1126..6fbfbacf5 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts
@@ -46,6 +46,8 @@ export const bookingsApi = {
checkUsage: (id: string) => apiClient.get
(`/bookings/${id}/usage`),
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
+ smartAssign: (bookingId: string) =>
+ apiClient.post(`/tickets/smart-assign/${bookingId}`, {}),
};
// Passengers API
diff --git a/apps/edr-passenger-web/backoffice/src/lib/utils.ts b/apps/edr-passenger-web/backoffice/src/lib/utils.ts
index 40131d2c7..c174e4f3a 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/utils.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/utils.ts
@@ -4,8 +4,9 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
+ currencyDisplay: 'code',
minimumFractionDigits: 2,
- }).format(amount / 100);
+ }).format(amount / 100).replace(/^([A-Z]{3})/, '$1 ').trim();
};
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {
From 08faf56c4f60f46919a7699ce8041879d0876692 Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Thu, 16 Jul 2026 06:43:12 +0000
Subject: [PATCH 62/67] fix: payment autopay on the payment init added for
testing
---
.../src/modules/billing/billing.service.ts | 34 +++++++++----------
1 file changed, 17 insertions(+), 17 deletions(-)
diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts
index 8c9461018..3b14d6f4c 100644
--- a/apps/edr-freight-api/src/modules/billing/billing.service.ts
+++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts
@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
- ) {}
+ ) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -432,7 +432,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
- (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
+ (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
@@ -991,26 +991,26 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
-//
+ //
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
- // DEMO: manually fire the gateway `payment.succeeded` callback here, without
- // waiting for real gateway settlement. Runs AFTER the paymentId link above so
- // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
- // remove — real settlement flips this via the `${source}.invoice.paid` handler.
- if (!result.immediateSuccess) {
- await this.payment.handlePaymentEvent({
- eventType: "payment.succeeded",
- eventId: `demo-${result.intentId}`,
- referenceId: invoice.sourceId,
- intentId: result.intentId,
- providerTxnId: result.providerTxnId,
- paidAt: (result.paidAt ?? new Date()).toISOString(),
- });
- }
+ // // DEMO: manually fire the gateway `payment.succeeded` callback here, without
+ // // waiting for real gateway settlement. Runs AFTER the paymentId link above so
+ // // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
+ // // remove — real settlement flips this via the `${source}.invoice.paid` handler.
+ // if (!result.immediateSuccess) {
+ // await this.payment.handlePaymentEvent({
+ // eventType: "payment.succeeded",
+ // eventId: `demo-${result.intentId}`,
+ // referenceId: invoice.sourceId,
+ // intentId: result.intentId,
+ // providerTxnId: result.providerTxnId,
+ // paidAt: (result.paidAt ?? new Date()).toISOString(),
+ // });
+ // }
if (result.immediateSuccess) {
await this.settleByPaymentId(
From 312014b6780cf611eeee48c5763874db0dfe15de Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 16 Jul 2026 07:13:24 +0000
Subject: [PATCH 63/67] fix first/last mile
---
.../src/pages/operations/FirstMilePage.tsx | 60 ++++++++++++++++++-
.../src/pages/operations/LastMilePage.tsx | 60 ++++++++++++++++++-
2 files changed, 114 insertions(+), 6 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
index a8c55ecf4..69812182f 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx
@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
+/**
+ * The mile bills as distance × pricePerKm in the vehicle's own currency, so a
+ * vehicle missing either field cannot produce an invoice line. Returns the
+ * human-readable gap, or null when the vehicle is billable.
+ */
+const pricingGap = (
+ v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
+): string | null => {
+ if (!v) return null;
+ const missing: string[] = [];
+ if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
+ if (!String(v.currency ?? "").trim()) missing.push("Currency");
+ return missing.length ? missing.join(" and ") : null;
+};
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
+ // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
+ // below rather than silently billing 0 once distances are entered.
+ const pricingGapById = useMemo(() => {
+ const map = new Map();
+ const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
+ if (v?.id) map.set(v.id, pricingGap(v));
+ };
+ for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
+ for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
+ add(activeRecord?.vehicle);
+ return map;
+ }, [vehiclesData, activeRecord]);
+
+ const vehicleLabelFor = (id: string) =>
+ assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
+
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
if (!targetIds.length) return;
+ // Backstop for rows the Select guard never saw (pre-filled reassignments).
+ const unpriced = vehicles
+ .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
+ .filter((v): v is { label: string; gap: string } => Boolean(v.gap));
+ if (unpriced.length) {
+ toast({
+ title: "Vehicle is not priced",
+ description: `${unpriced
+ .map((v) => `${v.label} (${v.gap} not set)`)
+ .join("; ")} — set it on the vehicle before assigning.`,
+ variant: "destructive",
+ });
+ return;
+ }
+
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
- onChange={(v) =>
- setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
- }
+ onChange={(v) => {
+ const gap = v ? pricingGapById.get(v) : null;
+ if (v && gap) {
+ toast({
+ title: "Vehicle is not priced",
+ description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
+ variant: "destructive",
+ });
+ return;
+ }
+ setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
+ }}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
index e5de54880..cd5d22c6b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -251,6 +251,20 @@ const billingIssues = (r: LastMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
+/**
+ * The mile bills as distance × pricePerKm in the vehicle's own currency, so a
+ * vehicle missing either field cannot produce an invoice line. Returns the
+ * human-readable gap, or null when the vehicle is billable.
+ */
+const pricingGap = (
+ v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
+): string | null => {
+ if (!v) return null;
+ const missing: string[] = [];
+ if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
+ if (!String(v.currency ?? "").trim()) missing.push("Currency");
+ return missing.length ? missing.join(" and ") : null;
+};
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
@@ -870,6 +884,22 @@ const LastMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
+ // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
+ // below rather than silently billing 0 once distances are entered.
+ const pricingGapById = useMemo(() => {
+ const map = new Map();
+ const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
+ if (v?.id) map.set(v.id, pricingGap(v));
+ };
+ for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
+ for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
+ add(activeRecord?.vehicle);
+ return map;
+ }, [vehiclesData, activeRecord]);
+
+ const vehicleLabelFor = (id: string) =>
+ assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
+
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -1018,6 +1048,21 @@ const LastMilePage = () => {
if (!targetIds.length) return;
+ // Backstop for rows the Select guard never saw (pre-filled reassignments).
+ const unpriced = vehicles
+ .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
+ .filter((v): v is { label: string; gap: string } => Boolean(v.gap));
+ if (unpriced.length) {
+ toast({
+ title: "Vehicle is not priced",
+ description: `${unpriced
+ .map((v) => `${v.label} (${v.gap} not set)`)
+ .join("; ")} — set it on the vehicle before assigning.`,
+ variant: "destructive",
+ });
+ return;
+ }
+
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1738,9 +1783,18 @@ const LastMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
- onChange={(v) =>
- setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
- }
+ onChange={(v) => {
+ const gap = v ? pricingGapById.get(v) : null;
+ if (v && gap) {
+ toast({
+ title: "Vehicle is not priced",
+ description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
+ variant: "destructive",
+ });
+ return;
+ }
+ setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
+ }}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
From 5f6d8f9294eea211fc52b036d8178daf8d826fef Mon Sep 17 00:00:00 2001
From: Nathnael
Date: Thu, 16 Jul 2026 07:17:22 +0000
Subject: [PATCH 64/67] fix: cache invalidation
---
.../pages/contracts/ContractClearanceDetailPage.tsx | 11 +++++++++--
.../src/pages/contracts/GlClearanceDetailPage.tsx | 9 ++++++++-
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
index 76666c320..619ce59ad 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
@@ -110,6 +110,13 @@ export default function ContractClearanceDetailPage() {
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
+ // react-query's imperative refetch() ignores `enabled`, so calling it while
+ // linkedBookingId is still undefined (pre-booking clearance) would fire
+ // GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it.
+ const refetchBookingMilestonesIfLinked = () => {
+ if (linkedBookingId) void refetchBookingMilestones();
+ };
+
if (isLoading) {
return (
@@ -261,7 +268,7 @@ export default function ContractClearanceDetailPage() {
onChanged={() => {
void refetch();
void refetchContract();
- void refetchBookingMilestones();
+ refetchBookingMilestonesIfLinked();
}}
/>
@@ -279,7 +286,7 @@ export default function ContractClearanceDetailPage() {
roleMode="ET"
onChanged={() => {
void refetch();
- void refetchBookingMilestones();
+ refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
index 3444e85f1..daff48939 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
@@ -105,6 +105,13 @@ export default function GlClearanceDetailPage() {
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
+ // react-query's imperative refetch() ignores `enabled`, so calling it while
+ // linkedBookingId is still undefined (pre-booking clearance) would fire
+ // GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it.
+ const refetchBookingMilestonesIfLinked = () => {
+ if (linkedBookingId) void refetchBookingMilestones();
+ };
+
if (isLoading) {
return (
@@ -275,7 +282,7 @@ export default function GlClearanceDetailPage() {
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
- void refetchBookingMilestones();
+ refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
From a4b330941215f32e24732bce9e1a07bd7f75b9c1 Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 16 Jul 2026 07:43:54 +0000
Subject: [PATCH 65/67] add seed
---
...0000000000-SeedEdrWagonFleetErNumbering.ts | 104 ++++++++++++++++++
.../src/scripts/seed-edr-wagons.ts | 34 +++---
2 files changed, 123 insertions(+), 15 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
new file mode 100644
index 000000000..fdfcf8be5
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
@@ -0,0 +1,104 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Re-seed the EDR wagon fleet onto the official ER numbering.
+ *
+ * Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
+ * on a `-NNNN` scheme and wrote the status as 'Available' — mixed case
+ * that never matches WagonStatus.Available ('AVAILABLE'), so status filters
+ * silently returned nothing. This seed uses the enum value.
+ *
+ * Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
+ * specs (capacity/length/tare) stay owned by wagon_types and are not touched —
+ * the types already exist and only the wagon↔type link is (re)established here.
+ */
+type FleetRow = {
+ code: string;
+ start: number;
+ end: number;
+ count: number;
+};
+
+/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */
+const FLEET: FleetRow[] = [
+ { code: 'PW2', start: 1, end: 220, count: 220 },
+ { code: 'CW4', start: 221, end: 330, count: 110 },
+ { code: 'CW3', start: 331, end: 350, count: 20 },
+ { code: 'KW2', start: 351, end: 370, count: 20 },
+ { code: 'KW3', start: 371, end: 390, count: 20 },
+ { code: 'NW5', start: 391, end: 940, count: 550 },
+ { code: 'BW1', start: 941, end: 950, count: 10 },
+ { code: 'GW2', start: 951, end: 1060, count: 110 },
+ { code: 'NW6', start: 1061, end: 1080, count: 20 },
+ { code: 'NW7', start: 1081, end: 1100, count: 20 },
+];
+
+const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
+
+export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
+ name = 'SeedEdrWagonFleetErNumbering2260000000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ // Full replacement: the ER range is the fleet of record, so any wagon
+ // outside it is stale seed data. Safe to hard-delete — containers and
+ // train_set_wagons null their link, wagon_movements cascade.
+ await queryRunner.query(`DELETE FROM freight.wagons;`);
+
+ for (const row of FLEET) {
+ if (row.end - row.start + 1 !== row.count) {
+ throw new Error(`wagon_range_mismatch:${row.code}`);
+ }
+
+ const [typeRecord] = await queryRunner.query(
+ `SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
+ [row.code],
+ );
+
+ if (!typeRecord?.id) {
+ throw new Error(`wagon_type_missing:${row.code}`);
+ }
+
+ // generate_series builds the range server-side — one round trip per type
+ // instead of 1100 individual INSERTs.
+ await queryRunner.query(
+ `
+ INSERT INTO freight.wagons (
+ wagon_number,
+ wagon_type_id,
+ status,
+ current_yard_id,
+ train_id,
+ sequence_number,
+ notes,
+ train_set_wagon_id,
+ current_train_schedule_id
+ )
+ SELECT
+ 'ER' || LPAD(seq::text, 4, '0'),
+ $1::uuid,
+ 'AVAILABLE',
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ NULL
+ FROM generate_series($2::int, $3::int) AS seq
+ ON CONFLICT (wagon_number) DO UPDATE SET
+ wagon_type_id = EXCLUDED.wagon_type_id,
+ status = EXCLUDED.status,
+ current_yard_id = EXCLUDED.current_yard_id,
+ updated_at = now();
+ `,
+ [typeRecord.id, row.start, row.end],
+ );
+ }
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
+ [wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts
index b6a6484f8..716532165 100644
--- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts
+++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts
@@ -1,5 +1,5 @@
import { AppDataSource } from '../data-source';
-import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
+import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
async function seedEdRWagons() {
await AppDataSource.initialize();
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
await queryRunner.connect();
await queryRunner.startTransaction();
- await new SeedEdRWagonFleet1750400000000().up(queryRunner);
+ await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
- const [summary] = await queryRunner.query(`
+ const summary = await queryRunner.query(`
SELECT
- COUNT(*)::int AS total,
- COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
- COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
- COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
- COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
- COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
- COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
- COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
- COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
- COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
+ wt.code,
+ wt.name,
+ COUNT(*)::int AS wagons,
+ MIN(w.wagon_number) AS first_wagon,
+ MAX(w.wagon_number) AS last_wagon,
+ COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
+ COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
- WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
+ WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
+ GROUP BY wt.code, wt.name
+ ORDER BY MIN(w.wagon_number);
+ `);
+
+ const [totals] = await queryRunner.query(`
+ SELECT COUNT(*)::int AS total FROM freight.wagons;
`);
await queryRunner.commitTransaction();
- console.log('Seeded EDR wagon fleet:', summary);
+ console.table(summary);
+ console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
From edbef5ccf20e8513f591c64284e54d846952447b Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Thu, 16 Jul 2026 11:01:31 +0300
Subject: [PATCH 66/67] add permissions
---
.../src/modules/tickets/tickets.controller.ts | 7 ++++---
.../src/seed/passenger-permissions.registry.ts | 9 +++++++--
apps/edr-passenger-web/backoffice/src/lib/permissions.ts | 1 +
.../src/providers/waafi/waafi.provider.ts | 6 +++++-
4 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
index e98245755..51d3b0630 100644
--- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
+++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts
@@ -2,7 +2,8 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Tickets')
@Controller('tickets')
@@ -10,7 +11,7 @@ export class TicketsController {
constructor(private service: TicketsService) {}
@Post('smart-assign/:bookingId')
- @PassengerAdmin()
+ @PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Smart seat assignment + ticket generation',
@@ -23,7 +24,7 @@ export class TicketsController {
}
@Post('generate/:bookingId')
- @PassengerAdmin()
+ @PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',
diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
index d62d5a261..05235e3a8 100644
--- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
+++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
@@ -22,6 +22,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
+ perm('7f3a1e9c-2b4d-4c8a-9e6f-1a2b3c4d5e6f', 'edr_passenger_app:tickets:generate', 'Generate tickets'),
perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'),
perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'),
perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
@@ -82,8 +83,9 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:passengers:manage',
},
tickets: {
- view: 'edr_passenger_app:tickets:view',
- manage: 'edr_passenger_app:tickets:manage',
+ view: 'edr_passenger_app:tickets:view',
+ manage: 'edr_passenger_app:tickets:manage',
+ generate: 'edr_passenger_app:tickets:generate',
},
payments: {
view: 'edr_passenger_app:payments:view',
@@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = {
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
+ PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.agents.view,
PASSENGER_PERMS.audit.view,
@@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = {
ticketOfficer: [
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
+ PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.bookings.view,
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.dashboard.view,
@@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = {
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
+ PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.dashboard.view,
],
diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
index 2a893f971..2c2d54f80 100644
--- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts
@@ -12,6 +12,7 @@ export const PERMS = {
tickets: {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
+ generate: 'edr_passenger_app:tickets:generate',
},
// ── Master Data ────────────────────────────────────────────────
diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts
index 5914fcd61..e7d78f752 100644
--- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts
+++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts
@@ -129,13 +129,17 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit {
requestBody,
);
+ this.logger.log(
+ `Waafi HPP_GETTRANINFO ref=${merchantOrderId} response: ${JSON.stringify(response)}`,
+ );
+
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
// wrongly resolved off a "no info" response.
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
- this.logger.debug(
+ this.logger.warn(
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
);
return {
From 4a04aa7e974f7d811a05765d520e9704cca9423b Mon Sep 17 00:00:00 2001
From: natib21
Date: Thu, 16 Jul 2026 08:13:22 +0000
Subject: [PATCH 67/67] fic
---
...0000000000-SeedEdrWagonFleetErNumbering.ts | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
index fdfcf8be5..717a48217 100644
--- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
+++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts
@@ -44,6 +44,14 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
+ // Wagon.wagonNumber declares `unique: true`, but some environments never got
+ // the constraint. Repair it here — the table is empty at this point, so the
+ // index build cannot fail on pre-existing duplicates.
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
+ ON freight.wagons (wagon_number);
+ `);
+
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
@@ -59,7 +67,9 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
}
// generate_series builds the range server-side — one round trip per type
- // instead of 1100 individual INSERTs.
+ // instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
+ // was deleted above, so a plain INSERT cannot collide, and the clause would
+ // otherwise hard-require a unique index this table lacks on some envs.
await queryRunner.query(
`
INSERT INTO freight.wagons (
@@ -83,12 +93,7 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
NULL,
NULL,
NULL
- FROM generate_series($2::int, $3::int) AS seq
- ON CONFLICT (wagon_number) DO UPDATE SET
- wagon_type_id = EXCLUDED.wagon_type_id,
- status = EXCLUDED.status,
- current_yard_id = EXCLUDED.current_yard_id,
- updated_at = now();
+ FROM generate_series($2::int, $3::int) AS seq;
`,
[typeRecord.id, row.start, row.end],
);