From a542a1389396eff4a092f281e845fe4fd9275d7e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 13 Jul 2026 13:38:28 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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 e785c117e4e050ecf206453e8f9cc51983caa12c Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 14 Jul 2026 16:08:33 +0300 Subject: [PATCH 06/13] 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 07/13] 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. + +