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. + + + + {!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} + /> + 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}
} +
+ + + {editing &&

Seat class cannot be changed. Delete and recreate to change.

} +
+
+
+ + setForm({ ...form, maxWeightKg: e.target.value })} /> +
+
+ + setForm({ ...form, maxPiecesCount: e.target.value })} /> +
+
+
+ + 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 ( + <> +
+ + + 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" + > +
+ {form.error && ( +
+ {form.error} +
+ )} + + {isAddMode ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+ {form.rule?.seatClass?.name ?? form.rule?.seatClassId} + {' · '}{form.rule?.route?.name ?? form.rule?.routeId} + {' · '}{form.rule?.passengerCategory} +
+ )} + +
+
+ + +
+
+ + +
+
+ +
+ setForm({ isOpen: false, rule: null, error: null })}>Cancel + + {isAddMode ? 'Create Override' : 'Update Override'} + +
+
+
+ + 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 ( + +
+ {error && ( +
+ {error} +
+ )} + + {!editingClass && allowRouteOverride && ( +
+ + + {isOverrideMode && ( +

+ Override applies only to this route. A matching global seat class must already exist. +

+ )} +
+ )} + +
+
+ + +
+ +
+ + +
+ + {isBedCoach && ( +
+ + +

+ {selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'} +

+
+ )} + + {!isOverrideMode && ( +
+ + + {!editingClass && suggestName() && ( +

+ Suggested:{' '} + +

+ )} +
+ )} + +
+ + + {suggestRate() && ( +

+ Official tariff:{' '} + + {' '}(stored as {Math.round(Number(suggestRate()) * 100)}) +

+ )} +
+ + {/* Insurance fee — different field name depending on mode */} +
+ + +
+ + {!isOverrideMode && ( +
+ + +
+ )} +
+ +
+ Cancel + + {editingClass ? 'Update Rate' : isOverrideMode ? 'Create Override' : 'Create Rate'} + +
+
+
+ ); +} 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'} + + )}
- - + {tabs.map(t => ( + + ))}
{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}
- )} -
- - - {editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

} -
-
-
- - setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} /> -
-
- - setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} /> -
-
-
- - setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} /> -

Amount charged per kg above the free allowance

-
-
- { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel - - {editingAllowance ? 'Update' : 'Save'} - -
-
-
- - -
- {formError && ( -
- {formError} -
- )} - -
-
- - -
- -
- - -
- - {isBedCoach && ( -
- - -

- {selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'} -

-
- )} - -
- - - {!editingClass && suggestName() && ( -

- Suggested:{' '} - -

- )} -
- -
- - - {suggestRate() && ( -

- Official tariff rate:{' '} - - {' '}(stored as {Math.round(Number(suggestRate()) * 100)}) -

- )} -
- -
- - -

Flat fee per passenger (e.g., travel insurance)

-
- -
- - -
-
- -
- Cancel - - {editingClass ? 'Update' : 'Create'} Rate - -
-
-
); } 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. + + 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 (
- {/* 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 - /*
+
Register -
*/ +
)} 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. +
+ +
+ + + 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" + > +
+ {form.error && ( +
{form.error}
+ )} + + {isAddMode ? ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ ) : ( +
+ + {form.rule && stopLabel(form.rule.originStopSequence)} → {form.rule && stopLabel(form.rule.destinationStopSequence)} + + {' · '}{form.rule?.seatClass?.name ?? form.rule?.seatClassId} + {form.rule?.nationality ? ` · ${form.rule.nationality}` : ' · All nationalities'} +
+ )} + +
+ + + {Object.keys(rateMap).length > 0 && ( +

+ Exchange rates applied at booking: {Object.entries(rateMap).map(([c, r]) => `1 ETB = ${r} ${c}`).join(', ')} +

+ )} +
+ +
+ setForm({ isOpen: false, rule: null, error: null })}>Cancel + + {isAddMode ? 'Create Override' : 'Update Override'} + +
+
+
+ + 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 ( + {/* span wrapper so the tooltip still fires on the disabled button */} + + + +
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 + } + /> + + + + + + + + {/* 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} - /> + {/* 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"}. -
- -
-
-

- 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"}.
+
- )} + {/* 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"}. -
- -
-
-

- 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"}.
+
- )} + {/* 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."} + + + + + )} + {needsManualGenerate && (