From 722874203b24187244df97c10f618b70b4a14bce Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 22 Aug 2026 06:09:36 +0000 Subject: [PATCH 01/10] fix issue --- .../src/pages/contracts/NewShipmentPage.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 477ec2bbb..783554b85 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -412,10 +412,9 @@ function NewShipmentBookingForm({ mode: "onChange", }); - // 20ft containers ride two per wagon. An odd total no longer blocks the - // booking — the server auto-pairs it with another customer's odd booking, or - // parks it as PENDING_CONSOLIDATION until one shows up (same consolidation - // gate the direct-booking flow already uses). + // 20ft containers ride two per wagon. On the COMPLETION page an odd total + // hard-blocks submit — odd (consolidated) bookings are GL's job in the + // backoffice. The direct-booking route keeps the consolidation notice. const watchedContainers = form.watch("containers"); const ft20Total = contract.freightType === "CONTAINER" @@ -424,6 +423,7 @@ function NewShipmentBookingForm({ .reduce((sum, l) => sum + Number(l.quantity || 0), 0) : 0; const hasOdd20ft = ft20Total % 2 === 1; + const blockOdd20ft = hasOdd20ft && Boolean(completeBookingId); // COMPLETION mode: fetch the booking — a changes-requested resubmit prefills // the form from it and shows the operations note + uploaded documents. @@ -580,6 +580,9 @@ function NewShipmentBookingForm({ // run it for every freight type; container contracts additionally get // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { + // Completion: odd 20ft counts never reach review — the red alert next to + // the button explains; odd (consolidated) bookings are GL's backoffice job. + if (blockOdd20ft) return; setPendingValues(values); validateMutation.reset(); validateMutation.mutate(buildDto(values)); @@ -744,7 +747,17 @@ function NewShipmentBookingForm({ Fix the highlighted fields before reviewing the price. ) : null} - {hasOdd20ft ? ( + {blockOdd20ft ? ( + } + mb="sm" + > + {`${ft20Total} is an odd number of 20ft containers. 20ft containers travel two per wagon, so they must be booked in even numbers — add one more or remove one (e.g. ${ft20Total + 1} or ${ft20Total - 1}).`} + + ) : hasOdd20ft ? ( Date: Sat, 22 Aug 2026 06:26:43 +0000 Subject: [PATCH 02/10] fix issue --- .../modules/train-scheduling/booking-batch.smart-need.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts index dd7797d33..c37003271 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -6,7 +6,7 @@ import { WagonStockLedger } from './wagon-stock-ledger.util'; * smartBulkNeed math in isolation: the private helpers it touches * (allowedDimsWithTypes) read only their arguments, so a bare prototype * instance is enough — no Nest wiring. - */ + */// describe('BookingBatchService.smartBulkNeed', () => { const service = Object.create(BookingBatchService.prototype) as BookingBatchService; const call = ( From 6ce4e13b4ab29353325604ec160c472537871f4c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 22 Aug 2026 10:22:32 +0300 Subject: [PATCH 03/10] fix: change schedule listing order from ascending to descending by departure time --- .../src/modules/schedules/schedules.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index ab795598b..838b2bf49 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -200,7 +200,7 @@ export class SchedulesService { liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, - orderBy: { departureAt: 'asc' }, + orderBy: { departureAt: 'desc' }, }); } From 3d741ab92854127df6b5845178a1ae6d6df957d8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 22 Aug 2026 10:48:34 +0300 Subject: [PATCH 04/10] fix: ( passenger-portal ) fix payment amount on booking confirmation --- .../portal/src/app/booking/confirmation/page.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 d60918752..7ed9aa0ad 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 @@ -659,8 +659,13 @@ export default function ConfirmationPage() { {(() => { // The server-confirmed settled amount is authoritative — prefer it over // any client-side session state, which can go stale (e.g. after a refresh). + // Despite its name, PaymentIntent.amountMinor holds MAJOR units — it is the + // charge amount produced by currencyService.*ToChargeMajor (see initiate() in + // payments.service.ts; reports.service.ts multiplies it by 100 to get real + // minor units). Do NOT divide by 100 here — the voucher does the same via + // fareIsMajorUnits. if (_booking?.payment?.amountMinor != null) { - return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`; + return `${_booking.payment.currency || 'ETB'} ${_booking.payment.amountMinor.toFixed(2)}`; } if (reviewedTotalMinor != null) return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`; From 81f7c0f0cd5d73bf130c71969bb3a4039c83306f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 22 Aug 2026 11:17:58 +0300 Subject: [PATCH 05/10] feat: coach utilization report with schedule filtering and export functionality --- .../src/modules/fleet/fleet.controller.ts | 7 +- .../src/modules/fleet/fleet.service.ts | 37 +- .../backoffice/src/app/coaches/page.tsx | 121 +----- .../app/reports/coach-utilization/page.tsx | 362 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 1 + 5 files changed, 400 insertions(+), 128 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index ee2a048f0..a8172e223 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -229,10 +229,11 @@ export class FleetController { } @Get('coaches/utilization') - @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' }) + @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' }) + @ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' }) @ApiResponse({ status: 200, description: 'Coach utilization data' }) - getCoachUtilization() { - return this.service.getCoachUtilization(); + getCoachUtilization(@Query('scheduleId') scheduleId?: string) { + return this.service.getCoachUtilization(scheduleId); } @Get('coaches/:id') diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 103cbbc4b..6a081f9ec 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -822,12 +822,35 @@ export class FleetService { }; } - async getCoachUtilization() { + async getCoachUtilization(scheduleId?: string) { + const where = scheduleId ? { scheduleId } : {}; + const coaches = await this.prisma.coach.findMany({ + where: scheduleId + ? { + assignments: { + some: { scheduleId }, + }, + } + : {}, include: { coachType: true, - seats: { select: { id: true, status: true } }, + seats: { + select: { + id: true, + status: true, + bookingSeats: { + where, + select: { id: true }, + }, + blocks: { + where, + select: { id: true, reasonCategory: true }, + }, + }, + }, assignments: { + where, include: { schedule: { select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } }, @@ -842,10 +865,12 @@ export class FleetService { return coaches.map((coach) => { const totalSeats = coach.seats.length; - const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length; - const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length; - const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length; - const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length; + const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length; + const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length; + const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length; + const availableSeats = scheduleId + ? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0) + : coach.seats.filter((s) => s.status === 'AVAILABLE').length; const totalAssignments = coach.assignments.length; const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0); const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0; diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 408ebb352..0b836f141 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -type Tab = 'types' | 'coaches' | 'utilization'; +type Tab = 'types' | 'coaches'; const getBedLabel = (bedPosition: string | null): string => { if (bedPosition === 'upper') return 'U'; @@ -152,8 +152,6 @@ function CoachesPageContent() { const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const [isBedCoach, setIsBedCoach] = useState(false); - const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); - const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const queryClient = useQueryClient(); @@ -169,12 +167,6 @@ function CoachesPageContent() { queryFn: () => fleetApi.getCoaches({}), }); - const { data: utilizationData, isLoading: utilizationLoading } = useQuery({ - queryKey: ['coach-utilization'], - queryFn: () => apiClient.get('/fleet/coaches/utilization'), - enabled: activeTab === 'utilization', - }); - // Coach Type Mutations const createCoachTypeMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data), @@ -531,16 +523,6 @@ function CoachesPageContent() { > Coaches - {/* Coach Types Tab */} @@ -591,105 +573,6 @@ function CoachesPageContent() { )} - {/* Utilization Tab */} - {activeTab === 'utilization' && (() => { - const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; - - const UTIL_COLS = [ - { key: 'number', label: 'Coach' }, - { key: 'coachType', label: 'Type' }, - { key: 'totalSeats', label: 'Total Seats' }, - { key: 'availableSeats', label: 'Available' }, - { key: 'bookedSeats', label: 'Booked' }, - { key: 'blockedSeats', label: 'Blocked' }, - { key: 'maintenanceSeats', label: 'Maintenance' }, - { key: 'utilizationRate', label: 'Utilization %' }, - { key: 'totalAssignments', label: 'Assignments' }, - { key: 'totalBookings', label: 'Total Bookings' }, - ]; - - const doExport = () => { - if (!rows.length) { alert('No data to export'); return; } - const headers = UTIL_COLS.map(c => c.label); - const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? ''))); - const dateStr = new Date().toISOString().split('T')[0]; - if (exportUtilFormat === 'pdf') { - const w = window.open('', '_blank')!; - w.document.write(`Coach Utilization Report`); - w.document.write(`

Coach Utilization Report — ${dateStr}

${headers.map(h => ``).join('')}`); - exportRows.forEach((r: string[]) => { w.document.write(`${r.map((v: string) => ``).join('')}`); }); - w.document.write('
${h}
${v}
'); - w.document.close(); w.print(); - } else if (exportUtilFormat === 'excel') { - const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n'); - const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url); - } else { - const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url); - } - setExportUtilModalOpen(false); - }; - - return ( -
-
- setExportUtilModalOpen(true)}>Export -
- {r.number} }, - { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, - { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, - { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, - { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, - { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, - { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, - { - key: 'utilizationRate', label: 'Utilization', - render: (r: any) => ( -
-
-
-
- {r.utilizationRate}% -
- ), - }, - { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, - { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, - ]} - data={rows} - actions={[]} - loading={utilizationLoading} - emptyMessage="No coach utilization data available" - /> - - setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm"> -
-
-

Export Format

-
- {(['csv', 'excel', 'pdf'] as const).map(fmt => ( - - ))} -
-
-
- setExportUtilModalOpen(false)}>Cancel - Export -
-
-
-
- ); - })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx new file mode 100644 index 000000000..6a3837c68 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Activity, BarChart3, Download, Search } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import ActionButton from "@/components/ui/ActionButton"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; + +interface ScheduleOption { + id: string; + label: string; +} + +interface CoachUtilizationRow { + id: string; + number: string; + coachType: string | null; + status: string | null; + totalSeats: number; + availableSeats: number; + bookedSeats: number; + blockedSeats: number; + maintenanceSeats: number; + utilizationRate: number; + totalAssignments: number; + totalBookings: number; +} + +export default function CoachUtilizationReportPage() { + const [scheduleId, setScheduleId] = useState(""); + const [search, setSearch] = useState(""); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["coach-utilization-report", scheduleId], + queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const schedules = schedulesRaw ?? []; + const rows = data ?? []; + + const filteredRows = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((row) => { + const coachType = row.coachType ?? ""; + const status = row.status ?? ""; + return ( + row.number.toLowerCase().includes(q) || + coachType.toLowerCase().includes(q) || + status.toLowerCase().includes(q) + ); + }); + }, [rows, search]); + + const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25); + + const summary = useMemo(() => { + if (!rows.length) return null; + + const totals = rows.reduce( + (acc, row) => { + acc.totalSeats += row.totalSeats; + acc.availableSeats += row.availableSeats; + acc.bookedSeats += row.bookedSeats; + acc.blockedSeats += row.blockedSeats; + acc.maintenanceSeats += row.maintenanceSeats; + acc.totalBookings += row.totalBookings; + acc.totalAssignments += row.totalAssignments; + return acc; + }, + { + totalSeats: 0, + availableSeats: 0, + bookedSeats: 0, + blockedSeats: 0, + maintenanceSeats: 0, + totalBookings: 0, + totalAssignments: 0, + }, + ); + + const avgUtilization = rows.length + ? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length + : 0; + + return { + totalCoaches: rows.length, + totalSeats: totals.totalSeats, + availableSeats: totals.availableSeats, + bookedSeats: totals.bookedSeats, + blockedSeats: totals.blockedSeats, + maintenanceSeats: totals.maintenanceSeats, + avgUtilization, + totalAssignments: totals.totalAssignments, + totalBookings: totals.totalBookings, + }; + }, [rows]); + + const doExport = () => { + if (!filteredRows.length) return; + + const headers = [ + "Coach", + "Type", + "Status", + "Total Seats", + "Available", + "Booked", + "Blocked", + "Maintenance", + "Utilization %", + "Assignments", + "Total Bookings", + ]; + + const rowsCsv = filteredRows.map((row) => [ + row.number, + row.coachType ?? "—", + row.status ?? "—", + String(row.totalSeats), + String(row.availableSeats), + String(row.bookedSeats), + String(row.blockedSeats), + String(row.maintenanceSeats), + `${row.utilizationRate}%`, + String(row.totalAssignments), + String(row.totalBookings), + ]); + + const csv = [ + headers.map((header) => `"${header}"`).join(","), + ...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")), + ].join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Coach Utilization Report

+

+ Occupancy, availability, and booking load by coach for a selected schedule. +

+
+ +
+
+
+ + +
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load coach utilization.

} +
+ + {!scheduleId && ( +
+ +

Choose a schedule to review coach occupancy

+

The report drills into availability, bookings, and maintenance status for each assigned coach.

+
+ )} + + {data && summary && ( + <> +
+
+

Coaches

+

{summary.totalCoaches}

+

Assigned coaches

+
+ +
+
+
+

Total Seats

+

{summary.totalSeats}

+

Across all coaches

+
+ +
+
+ +
+
+
+

Booked

+

{summary.bookedSeats}

+

Occupied seats

+
+ +
+
+ +
+
+
+

Available

+

{summary.availableSeats}

+

Open seats

+
+ +
+
+ +
+
+
+

Blocked

+

{summary.blockedSeats}

+

Unavailable seats

+
+ +
+
+ +
+
+
+

Avg Utilization

+

+ {summary.avgUtilization.toFixed(1)}% +

+

Across coach set

+
+ +
+
+
+ +
+
+

+ Coach Details +

+
+
+ + { + setSearch(event.target.value); + reset(); + }} + /> +
+ + Export CSV + +
+
+ +
+ + + + {[ + "Coach", + "Type", + "Status", + "Total Seats", + "Available", + "Booked", + "Blocked", + "Maintenance", + "Utilization", + "Assignments", + "Bookings", + ].map((header) => ( + + ))} + + + + {paged.map((row) => ( + + + + + + + + + + + + + + ))} + + {paged.length === 0 && ( + + + + )} + +
+ {header} +
{row.number}{row.coachType ?? "—"}{row.status ?? "—"}{row.totalSeats}{row.availableSeats}{row.bookedSeats}{row.blockedSeats}{row.maintenanceSeats} +
+
+
+
+ {row.utilizationRate.toFixed(1)}% +
+
{row.totalAssignments}{row.totalBookings}
+ No coach utilization rows found +
+
+ +
+ + )} + + {!data && !isLoading && scheduleId && ( +
+ No utilization data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..3fc80428b 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ items: [ { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, + { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, From e426e0c16e06a1dee04754609a658404dae35ff0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 22 Aug 2026 09:06:11 +0000 Subject: [PATCH 06/10] feat(train-scheduling): dedicated load/unload permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carves cargo load/unload confirmation out of the coarse trainScheduling.update permission into its own guard (TrainSchedulingLoad/TrainSchedulingUnload), covering import, export, and intercity — the generic per-booking route already serves all directions, and the intercity-specific route gets the same two keys. Adds the catalog entries and grants them to operationsOfficer/director alongside the existing .update grant so current access is unchanged. --- .../src/common/booking-guards.ts | 14 +++++++++- .../train-scheduling.controller.ts | 10 ++++--- .../src/seed/freight-permissions.registry.ts | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index f9eab4d39..68bbdcba4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -75,7 +75,7 @@ export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); // Granular train-scheduling actions replace the retired coarse manage: -// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…), +// create a schedule, update (assign/consist/finalize/dispatch/arrive…), // cancel a schedule, reschedule (+ maintenance), and manage global rules. export const TrainSchedulingCreate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.create); @@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () => export const TrainSchedulingUpdate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.update); +/** + * Confirm a booking's cargo loaded/unloaded at a yard — carved out of the + * coarse `update` so it can be granted independently of general schedule + * editing. Same two keys gate import, export, and intercity movements alike: + * the generic per-booking route and the intercity-specific one both use them. + */ +export const TrainSchedulingLoad = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.load); + +export const TrainSchedulingUnload = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unload); + export const TrainSchedulingCancel = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 6afcf9380..03eb5ad88 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -16,7 +16,9 @@ import { TrainSchedulingCancel, TrainSchedulingCreate, TrainSchedulingEditTrainNumber, + TrainSchedulingLoad, TrainSchedulingReschedule, + TrainSchedulingUnload, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, @@ -598,7 +600,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/load") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ summary: "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", @@ -611,7 +613,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/unload") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", @@ -624,7 +626,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/load") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", }) @@ -636,7 +638,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/unload") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", 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 5fde44f2d..8204ee2d3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1469,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:rules_manage", "Manage global scheduling rules", ), + // Carved out of the coarse `update` — confirming a booking's cargo loaded/ + // unloaded at a yard, across import, export, and intercity movements alike + // (the same schedules/:id/bookings/:bookingId/{load,unload} + intercity + // routes serve all three directions). + perm( + "a2a00001-0001-4000-8000-000000000006", + "edr_freight_app:train_scheduling:load", + "Confirm cargo loaded (import, export, intercity)", + ), + perm( + "a2a00001-0001-4000-8000-000000000007", + "edr_freight_app:train_scheduling:unload", + "Confirm cargo unloaded (import, export, intercity)", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) @@ -1996,6 +2010,15 @@ export const FREIGHT_PERMS = { cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", + /** + * Confirm a booking's cargo loaded/unloaded at a yard — carved out of the + * coarse `update` so load/unload can be granted independently of general + * schedule editing. Covers import, export, and intercity alike: the + * generic per-booking route and the intercity-specific one both gate on + * these same two keys. + */ + load: "edr_freight_app:train_scheduling:load", + unload: "edr_freight_app:train_scheduling:unload", dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", @@ -2602,6 +2625,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2822,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, From 7f5e8349dad0ec18704ae2277ae8e2b7aff0ff1d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 22 Aug 2026 09:16:17 +0000 Subject: [PATCH 07/10] Intercity andd import export gating --- .../IntercityRideAlongPanel.tsx | 23 ++++++- .../trainScheduling/LogPassYardWorkModal.tsx | 18 ++++-- .../ScheduleWorkspacePanel.tsx | 40 ++++++++---- .../warehouses/ReceiveInventoryModal.tsx | 64 +++++++++++-------- .../backoffice/src/lib/permissions.ts | 3 + .../src/pages/warehouses/IntercityPage.tsx | 57 ++++++++++------- 6 files changed, 134 insertions(+), 71 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx index 1636cfa6d..0342b9ae9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx @@ -15,6 +15,8 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { @@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({ direction: string | null | undefined; }) { const { toast } = useToast(); + const { user } = useAuth(); + const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload); const queryClient = useQueryClient(); const [selected, setSelected] = useState([]); @@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({ {row.status === "PAID" && ( - + + + + void; }) { const { toast } = useToast(); + const { user } = useAuth(); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload); const { data: trains = [], isLoading } = useQuery( api.warehouses.importArriveQueue.queryOptions({ enabled }), ); @@ -2489,23 +2497,25 @@ export function ImportArriveQueueTab({ > Open - + + + diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 177976b28..d8ef47f35 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -104,6 +104,9 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:train_scheduling:view", create: "edr_freight_app:train_scheduling:create", update: "edr_freight_app:train_scheduling:update", + /** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */ + load: "edr_freight_app:train_scheduling:load", + unload: "edr_freight_app:train_scheduling:unload", cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index cfda31629..f756af834 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; import ListControls from "@/components/common/ListControls"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path. @@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => { function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { const { toast } = useToast(); + const { user } = useAuth(); + const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload); const queryClient = useQueryClient(); const refresh = () => queryClient.invalidateQueries({ @@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {/* Work the cargo right here while the train is at the yard. */} {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && ( - + + + )} {r.trainScheduleId && atDestination(r) && isRiding(r) && ( - + + + )} From 8e6fc09aacb05837575594ceaaf94abe0b91b930 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 23 Aug 2026 03:55:54 +0000 Subject: [PATCH 08/10] feat(train-scheduling): mid-route consist changes, audit history, safer workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 --- ...60000000000-SchedulePlannedWagonCouples.ts | 37 + ...000000000-AdjustmentLogNullableSchedule.ts | 24 + .../booking-wagon-cancellation.service.ts | 17 +- .../modules/bookings/bookings.controller.ts | 14 +- .../contracts/contract-booking.service.ts | 28 +- .../contract-expired-rebook-gate.spec.ts | 65 ++ .../schedule-wagon-adjustment-log.entity.ts | 5 +- .../entities/train-schedule.entity.ts | 18 + .../train-scheduling/booking-batch.service.ts | 5 + .../train-scheduling.controller.ts | 2 +- .../corridor-capacity.util.spec.ts | 37 + .../corridor-capacity.util.ts | 19 + .../dto/update-schedule-wagon-yards.dto.ts | 56 +- .../train-scheduling/edge-load.util.spec.ts | 59 ++ .../train-scheduling/edge-load.util.ts | 50 ++ .../services/train-scheduling.service.ts | 640 +++++++++++++++++- .../utils/wagon-plan.util.spec.ts | 88 +++ .../train-scheduling/utils/wagon-plan.util.ts | 60 +- .../train-scheduling/wagon-plan-flex.util.ts | 17 + .../wagon-stock-ledger.util.spec.ts | 49 ++ .../wagon-stock-ledger.util.ts | 26 + .../trains/train-builder.controller.ts | 19 + .../modules/trains/train-builder.service.ts | 125 +++- .../trainBuilder/DetachedWagonsPanel.tsx | 194 ++++++ .../trainBuilder/TrainHistoryPanel.tsx | 140 ++++ .../ScheduleWagonYardPanel.tsx | 614 ++++++++++++++--- .../ScheduleWorkspacePanel.tsx | 163 ++++- .../trainBuilder/TrainBuilderDetailPage.tsx | 38 +- .../backoffice/src/services/api.ts | 18 + .../src/services/trainBuilder.service.ts | 51 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 6 +- .../components/WagonCancellationCard.tsx | 26 +- .../components/WagonsTab.tsx | 13 +- .../portal/src/services/bookings.service.ts | 11 +- 34 files changed, 2532 insertions(+), 202 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts create mode 100644 apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx diff --git a/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts new file mode 100644 index 000000000..ff87201f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3660000000000-SchedulePlannedWagonCouples.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-schedule consist-change plan, executed automatically as the trip + * proceeds (dispatch / checkpoint logs): + * + * - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this + * departure couples onto the train at a route stop. They join the built + * train permanently when the train reaches that stop. + * - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see + * planned_wagon_cut_yards) flagged as REAL cuts: the built train + * permanently loses the wagon at its cut yard, instead of the default + * soft cut where it stays in the build and only sits out this trip. + */ +export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface { + name = 'SchedulePlannedWagonCouples3660000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts new file mode 100644 index 000000000..6bd2bdd62 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3670000000000-AdjustmentLogNullableSchedule.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A consist adjustment made from the TRAIN BUILDER on a train with no live + * schedule still belongs in the wagon adjustment history — it just has no + * schedule to point at. Relax the NOT NULL so builder detaches/attaches can + * be recorded; every existing reader filters BY train_schedule_id or + * train_id, so nullable rows are invisible to them. + */ +export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface { + name = 'AdjustmentLogNullableSchedule3670000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.schedule_wagon_adjustment_logs + ALTER COLUMN train_schedule_id DROP NOT NULL + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // No-op: restoring NOT NULL would fail on any builder-origin rows written + // while this migration was live, re-introducing the outage it fixed. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 6736d7784..c1f4114a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -698,8 +698,8 @@ export class BookingWagonCancellationService { booking, whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', whole - ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.` - : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, + ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.` + : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`, ); } this.logger.log( @@ -759,16 +759,6 @@ export class BookingWagonCancellationService { if (!source.contractId) { throw new BadRequestException('The original booking has no contract to rebook under.'); } - // Friendly pre-check; createUnderContract re-asserts inside its own guards. - if ( - source.contractValidUntil && - new Date(source.contractValidUntil).getTime() < Date.now() - ) { - throw new BadRequestException( - 'Contract validity has expired — ask EDR staff to extend the contract before rebooking.', - ); - } - const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; @@ -779,6 +769,9 @@ export class BookingWagonCancellationService { // System actor: carries the create-booking key so the GL gate passes on // Path B (customs-clearance) contracts; harmless on Path A. { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + // The freight was paid while the contract was live — the credit stays + // redeemable even after the contract's validity lapses. + { allowExpiredContract: true }, ); const newBookingId = created.booking.id; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 7cc3d60d8..b216df4cf 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -657,16 +657,18 @@ export class BookingsController { } @Post('wagon-cancellations/:cancellationId/withdraw') - @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) + @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.' }) async withdrawWagonCancellation( @Param('cancellationId', ParseUUIDPipe) cancellationId: string, @CurrentUser() user: TCurrentUser, ) { - await this.assertWagonCancellationActor( - cancellationId, - user, - FREIGHT_PERMS.bookings.wagonCancellationVoid, - ); + // Customer cancellations are irreversible from the portal — no owner + // fallback here. Only staff holding the void permission can revert one. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) { + throw new ForbiddenException( + 'A cancellation request cannot be withdrawn from the portal — contact EDR staff.', + ); + } return this.wagonCancellationService.withdraw(cancellationId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 52cc2a51d..f4236ce7f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -140,6 +140,14 @@ export class ContractBookingService { dto: CreateBookingUnderContractDto, user?: { id?: string } | null, actorPermissions?: unknown, + opts?: { + /** + * Wagon-cancellation credit rebook only: the freight was paid while the + * contract was live, so redeeming the credit is allowed even after the + * contract's validity lapsed. Never set for a genuinely new booking. + */ + allowExpiredContract?: boolean; + }, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); @@ -180,8 +188,13 @@ export class ContractBookingService { actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); - await this.assertNotExpired(contract); - const createdByRole = await this.assertGate(contract, isGlActor); + if (!opts?.allowExpiredContract) await this.assertNotExpired(contract); + const createdByRole = await this.assertGate( + contract, + isGlActor, + false, + opts?.allowExpiredContract, + ); // ONE_TIME: a single shipment at a time. The slot frees only if the prior // booking reached a terminal state (e.g. payment expired without shipping), @@ -1203,6 +1216,7 @@ export class ContractBookingService { contract: Contract, isGlActor: boolean, isInitiate = false, + allowExpired = false, ): Promise { // Suspended contracts are frozen for everyone, GL included — say so instead // of letting the executed-status check below give a misleading reason. @@ -1225,7 +1239,10 @@ export class ContractBookingService { } // No contract clearance cycle exists on either kind now — clearance runs // on the booking, so an executed/active contract is the only gate here. - if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { + if ( + !['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) && + !(allowExpired && contract.status === 'EXPIRED') + ) { throw new BadRequestException( 'Contract must be fully executed before booking a shipment.', ); @@ -1234,7 +1251,10 @@ export class ContractBookingService { } // Path A — customer (or staff) once the contract is executed. - if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { + if ( + !['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) && + !(allowExpired && contract.status === 'EXPIRED') + ) { throw new BadRequestException( 'Contract must be fully executed before booking a shipment.', ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts new file mode 100644 index 000000000..d44cf4ee6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expired-rebook-gate.spec.ts @@ -0,0 +1,65 @@ +import { ContractBookingService } from './contract-booking.service'; + +/** + * Wagon-cancellation credit rebook must work after the contract lapses (the + * freight was paid while it was live), while every other create path stays + * blocked. assertGate is the status gate createUnderContract runs; this pins + * the EXPIRED carve-out to the allowExpired flag. + */ +describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => { + // assertGate only reads contract fields — no constructor deps needed. + const service = Object.create( + ContractBookingService.prototype, + ) as ContractBookingService; + const gate = ( + contract: Record, + allowExpired: boolean, + ): Promise => + ( + service as unknown as { + assertGate: ( + c: unknown, + gl: boolean, + init: boolean, + allowExpired: boolean, + ) => Promise; + } + ).assertGate(contract, true, false, allowExpired); + + it('refuses an EXPIRED contract on the normal create path', async () => { + await expect( + gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false), + ).rejects.toThrow(/fully executed/i); + }); + + it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => { + await expect( + gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true), + ).resolves.toBe('STAFF'); + }); + + it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => { + await expect( + gate( + { + status: 'EXPIRED', + contractKind: 'GENERAL', + customsClearingEnabled: true, + }, + true, + ), + ).resolves.toBe('GL_ET'); + }); + + it('still refuses a SUSPENDED contract even for a rebook', async () => { + await expect( + gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true), + ).rejects.toThrow(/suspended/i); + }); + + it('does not open the gate for other non-executed statuses', async () => { + await expect( + gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true), + ).rejects.toThrow(/fully executed/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 56459870c..103f4c2b7 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; @Index(['trainScheduleId']) @Index(['trainId']) export class ScheduleWagonAdjustmentLog extends BaseEntity { - @Column({ name: 'train_schedule_id', type: 'uuid' }) - trainScheduleId!: string; + /** Null when the change was made from the train builder with no live schedule. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId!: string | null; @Column({ name: 'train_id', type: 'uuid' }) trainId!: string; diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 280b7f83b..fdd76861d 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -139,6 +139,24 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true }) plannedWagonCutYards?: Record | null; + /** + * LOOSE wagons this departure plans to COUPLE onto the train at a route + * stop: `{ wagonId: pickupYardId }`. They join the built train permanently + * when the trip reaches that stop (dispatch for the origin, checkpoint log + * for mid-route stops). + */ + @Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true }) + plannedWagonCouples?: Record | null; + + /** + * Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built + * train permanently loses the wagon at its cut yard. Absent from this list, + * a cut is soft — the wagon sits out the rest of this trip but stays in + * the build. + */ + @Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true }) + plannedWagonRealCuts?: string[] | null; + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; 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 cb9cc8ae1..3469413ca 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 @@ -100,6 +100,7 @@ import { CorridorBudget, CorridorLeg, OverageTolerance, + addCoupledWagons, stopYardsFor, subtractCutWagons, } from './corridor-capacity.util'; @@ -5064,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit { stock.byYardId, budget.stops, ); + // Wagons staff cut mid-route are not stock past their cut stop. + ledger.debitCutWagons(stock.cutWagons ?? []); // Debit what is already committed, per boarding yard and wagon type — the // same bookings the corridor budget subtracted. A booking with no resolvable // wagon type still occupies steel, so it drains any type at its yard. @@ -5385,6 +5388,8 @@ export class BookingBatchService implements OnModuleInit { // ponytail: the wagon-type stock ledger stays cut-blind; bucket // builtTrainStock by (yard, reach) if mixed-type cut trains appear. subtractCutWagons(budget, schedule.plannedWagonCutYards); + // Planned couples add a slot from their couple stop onward. + addCoupledWagons(budget, schedule.plannedWagonCouples); for (const b of await this.committedBookings(schedule, excludeBookingIds)) { budget.subtract( this.needFor(b, wagonDims), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 6afcf9380..6a3ace1fe 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -222,7 +222,7 @@ export class TrainSchedulingController { @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateScheduleWagonYardsDto, ) { - return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves); + return this.trainSchedulingService.updateScheduleWagonYards(id, dto); } @Post("schedules/:id/adjust-consist") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts index 358c7a4db..fafc13a2c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -1,4 +1,5 @@ import { + addCoupledWagons, Capacity, CorridorBudget, orientStopsToSchedule, @@ -220,3 +221,39 @@ describe('corridor-capacity.util — stop orientation and fallback', () => { expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']); }); }); + +describe('corridor-capacity.util — addCoupledWagons', () => { + const stops = ['a', 'b', 'c', 'd']; + const wagonsOnly: Capacity = { + wagons: 10, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }; + const remaining = (budget: CorridorBudget, from: string, to: string): number => + budget.remainingFor(budget.legOf(from, to)!).wagons; + + it('credits every edge at/after the couple stop', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' }); + expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything + expect(remaining(budget, 'b', 'c')).toBe(11); + expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon + }); + + it('nets against cuts on the same budget', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + subtractCutWagons(budget, { 'w-cut': 'c' }); + addCoupledWagons(budget, { 'w-new': 'c' }); + expect(remaining(budget, 'a', 'c')).toBe(10); + expect(remaining(budget, 'c', 'd')).toBe(10); // cut −1, couple +1 + expect(remaining(budget, 'a', 'd')).toBe(10); + }); + + it('ignores off-corridor and destination couple yards, and a missing plan', () => { + const budget = new CorridorBudget(stops, wagonsOnly); + addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' }); + addCoupledWagons(budget, null); + addCoupledWagons(budget, undefined); + expect(remaining(budget, 'a', 'd')).toBe(10); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index 3dbc8a2bf..afb8367f8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -124,6 +124,25 @@ export function subtractCutWagons( } } +/** + * Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the + * train mid-route: each coupled wagon adds a slot on every edge at/after its + * couple stop ([couple, destination)). A couple yard not on the corridor — + * or equal to the destination — is ignored; updateScheduleWagonYards owns + * rejecting it. + */ +export function addCoupledWagons( + budget: CorridorBudget, + couplePlan: Record | null | undefined, +): void { + if (!couplePlan) return; + const destination = budget.stops[budget.stops.length - 1]; + for (const coupleYardId of Object.values(couplePlan)) { + const leg = budget.legOf(coupleYardId, destination); + if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg); + } +} + /** Overage a locomotive may absorb beyond its base caps. */ export interface OverageTolerance { weightTons: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts index 528e7a19c..127f2dc0f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-wagon-yards.dto.ts @@ -1,6 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMaxSize, IsArray, IsOptional, IsUUID, ValidateIf, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsOptional, + IsUUID, + ValidateIf, + ValidateNested, +} from 'class-validator'; export class ScheduleWagonYardMoveDto { @ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." }) @@ -25,6 +33,27 @@ export class ScheduleWagonYardMoveDto { @ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null) @IsUUID() cutYardId?: string | null; + + @ApiPropertyOptional({ + description: + 'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.', + }) + @IsOptional() + @IsBoolean() + realCut?: boolean; +} + +export class ScheduleWagonCoupleDto { + @ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' }) + @IsUUID() + wagonId!: string; + + @ApiProperty({ + format: 'uuid', + description: 'Pickup stop the wagon joins the train at. It must physically stand there.', + }) + @IsUUID() + yardId!: string; } export class UpdateScheduleWagonYardsDto { @@ -33,9 +62,32 @@ export class UpdateScheduleWagonYardsDto { description: 'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.', }) + @IsOptional() @IsArray() @ArrayMaxSize(500) @ValidateNested({ each: true }) @Type(() => ScheduleWagonYardMoveDto) - moves!: ScheduleWagonYardMoveDto[]; + moves?: ScheduleWagonYardMoveDto[]; + + @ApiPropertyOptional({ + type: [ScheduleWagonCoupleDto], + description: + 'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => ScheduleWagonCoupleDto) + couple?: ScheduleWagonCoupleDto[]; + + @ApiPropertyOptional({ + type: [String], + description: 'Wagon ids to remove from the couple plan (before execution).', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsUUID('all', { each: true }) + uncouple?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts new file mode 100644 index 000000000..43dfaa95d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.spec.ts @@ -0,0 +1,59 @@ +import { computeEdgeLoads } from './edge-load.util'; + +describe('edge-load.util — computeEdgeLoads', () => { + // gmp -> lebu -> mojo -> adama -> dct: 4 edges. + const EDGES = 4; + const wagon = (fromEdge: number, toEdge: number) => ({ + fromEdge, + toEdge, + tareTons: 25, + lengthMeters: 17, + }); + + it('an uncut whole-route consist loads every edge flat', () => { + const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []); + for (const e of loads) { + expect(e.weightTons).toBe(50); + expect(e.lengthMeters).toBe(34); + } + }); + + it('a cut frees tare and length on the edges past the cut', () => { + // One wagon cut at mojo (edge index 2): rides edges 0-1 only. + const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []); + expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 }); + expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 }); + expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 }); + }); + + it('a couple adds tare and length only from its couple stop', () => { + const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []); + expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 }); + expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 }); + }); + + it('cut-then-couple at the same stop nets to a flat load', () => { + const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []); + for (const e of loads) { + expect(e.weightTons).toBe(25); + expect(e.lengthMeters).toBe(17); + } + }); + + it('cargo weighs only the edges of its own leg', () => { + const loads = computeEdgeLoads( + EDGES, + [wagon(0, 4)], + [{ fromEdge: 1, toEdge: 3, weightTons: 60 }], + ); + expect(loads[0].weightTons).toBe(25); + expect(loads[1].weightTons).toBe(85); + expect(loads[2].weightTons).toBe(85); + expect(loads[3].weightTons).toBe(25); + }); + + it('clamps out-of-range spans instead of throwing', () => { + const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []); + for (const e of loads) expect(e.weightTons).toBe(25); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts new file mode 100644 index 000000000..45e46360f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/edge-load.util.ts @@ -0,0 +1,50 @@ +/** + * Per-corridor-edge physical load of a train: tare + length of the wagons + * spanning each edge, plus the cargo weight riding it. Used to validate that + * a planned mid-route COUPLE keeps every leg within the locomotives' pull + * weight and train length limits — a wagon cut at Mojo frees its tare/length + * on the edges past Mojo, a wagon coupled there adds its own only from there. + */ + +export interface EdgeLoad { + weightTons: number; + lengthMeters: number; +} + +export interface EdgeWagonSpan { + /** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */ + fromEdge: number; + toEdge: number; + tareTons: number; + lengthMeters: number; +} + +export interface EdgeCargoLeg { + fromEdge: number; + toEdge: number; + weightTons: number; +} + +export function computeEdgeLoads( + edgeCount: number, + wagonSpans: readonly EdgeWagonSpan[], + cargoLegs: readonly EdgeCargoLeg[], +): EdgeLoad[] { + const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({ + weightTons: 0, + lengthMeters: 0, + })); + const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length); + for (const span of wagonSpans) { + for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) { + loads[e].weightTons += span.tareTons; + loads[e].lengthMeters += span.lengthMeters; + } + } + for (const cargo of cargoLegs) { + for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) { + loads[e].weightTons += cargo.weightTons; + } + } + return loads; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 35885f4cd..10e2b82bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -137,11 +137,13 @@ import { type WagonPlanSlot, } from '../utils/wagon-plan.util'; import { + addCoupledWagons, CorridorBudget, orientStopsToSchedule, subtractCutWagons, } from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { computeEdgeLoads } from '../edge-load.util'; import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { defaultPlannedWagonYards, @@ -2199,7 +2201,16 @@ export class TrainSchedulingService { // so their tare rides on top of the binding edge. const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); const scheduleStops = await this.stopYardsForSchedule(schedule); - const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops); + // Same leg map the validator used: cargo weighs only the edges its booking + // rides, so a Dire Dawa boarder never inflates the Djibouti leg. + const commitLegByBookingId = new Map( + bookings.flatMap((b) => { + const from = scheduleStops.indexOf(b.originYardId); + const to = scheduleStops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); + const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops, commitLegByBookingId); const stopLabels = await this.yardLabelMap(scheduleStops); // Each edge is its own consist — name EVERY leg that breaks the limit, // not just the heaviest figure, so staff see where along A→…→E it fails. @@ -2902,6 +2913,56 @@ export class TrainSchedulingService { { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, ); } + // Planned couples boarding at the ORIGIN join the built train now — the + // departure is the moment they are physically hooked on. Mid-route + // couples join at their stop's checkpoint log instead. + const dispatchTrainId = schedule.trainSet?.trainId ?? null; + const originCouples = Object.entries(schedule.plannedWagonCouples ?? {}).filter( + ([, yardId]) => yardId === schedule.originStationId, + ); + if (originCouples.length && dispatchTrainId) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: dispatchTrainId }, + select: { id: true, sequenceNumber: true }, + }); + let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + for (const [coupleWagonId, coupleYardId] of originCouples) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: coupleWagonId }, lock: { mode: 'pessimistic_write' } }); + if (!wagon) continue; + if (wagon.trainId === dispatchTrainId) continue; // already joined — self-heal + if ( + wagon.trainId || + wagon.status !== WagonStatus.Available || + wagon.currentTrainScheduleId || + wagon.currentYardId !== coupleYardId + ) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is planned to couple at dispatch but is no longer free at the origin yard — remove the couple in the Schedule yards tab or free the wagon`, + ); + } + maxSeq += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: dispatchTrainId, + sequenceNumber: maxSeq, + status: WagonStatus.Assigned, + currentTrainScheduleId: scheduleId, + }); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: dispatchTrainId, + action: 'ADD', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt: now, + }), + ); + } + } for (const sb of schedule.scheduleBookings ?? []) { await this.bookingsRepository.updateSchedulingFields( sb.bookingId, @@ -4407,19 +4468,57 @@ export class TrainSchedulingService { // bound to the schedule is riding empty. Matching against ALL passed // yards, not just this one, self-heals skipped checkpoint logs. const cutPlan = schedule.plannedWagonCutYards ?? {}; + const realCutIds = new Set(schedule.plannedWagonRealCuts ?? []); + const builtTrainId = schedule.trainSet?.trainId ?? null; const cutNow = Object.entries(cutPlan).filter(([, yardId]) => passedYardIds.includes(yardId), ); + let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); // Already settled earlier (or re-pinned elsewhere) — not ours to move. if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; - await manager.getRepository(Wagon).update(wagonId, { - currentYardId: cutYardId, - currentTrainScheduleId: null, - trainSetWagonId: null, - status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, - }); + if (realCutIds.has(wagonId) && builtTrainId) { + // REAL cut: the built train permanently loses the wagon here. + await manager.getRepository(Wagon).update(wagonId, { + currentYardId: cutYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + trainId: null, + sequenceNumber: null, + importTrainNumber: null, + exportTrainNumber: null, + status: WagonStatus.Available, + }); + // Any slot of this train's schedules still pinned to it is stale. + await manager.query( + `UPDATE freight.train_set_wagons SET physical_wagon_id = NULL + WHERE physical_wagon_id = $1 + AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, + [wagonId, builtTrainId], + ); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action: 'REMOVE', + wagonId, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: cutYardId, + occurredAt, + }), + ); + realCutHappened = true; + } else { + // Soft cut: sits out the rest of this trip, stays in the build. + await manager.getRepository(Wagon).update(wagonId, { + currentYardId: cutYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } await manager.getRepository(WagonMovement).save( manager.getRepository(WagonMovement).create({ wagonId, @@ -4431,6 +4530,65 @@ export class TrainSchedulingService { }), ); } + // Keep the coupling order gapless after permanent removals. + if (realCutHappened && builtTrainId) { + const remaining = await manager.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + order: { sequenceNumber: 'ASC' }, + }); + for (const [i, w] of remaining.entries()) { + if (w.sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(w.id, { sequenceNumber: i + 1 }); + } + } + } + // Planned COUPLES standing at a passed stop join the train here — + // before the position fix below, so they ride it from this checkpoint + // on. Unavailable wagons are skipped silently (a checkpoint log must + // never fail on a missing planned couple); passedYardIds self-heals + // skipped logs, and an already-joined wagon has trainId set. + const couplePlan = schedule.plannedWagonCouples ?? {}; + const coupleNow = Object.entries(couplePlan).filter(([, yardId]) => + passedYardIds.includes(yardId), + ); + if (coupleNow.length && builtTrainId) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + select: { id: true, sequenceNumber: true }, + }); + let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + for (const [wagonId, coupleYardId] of coupleNow) { + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if ( + !wagon || + wagon.trainId || + wagon.status !== WagonStatus.Available || + wagon.currentTrainScheduleId || + wagon.currentYardId !== coupleYardId + ) { + continue; + } + maxSeq += 1; + await manager.getRepository(Wagon).update(wagonId, { + trainId: builtTrainId, + sequenceNumber: maxSeq, + status: WagonStatus.Assigned, + currentTrainScheduleId: scheduleId, + }); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action: 'ADD', + wagonId, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt, + }), + ); + } + } await manager .getRepository(Wagon) .createQueryBuilder() @@ -4657,14 +4815,52 @@ export class TrainSchedulingService { schedule.plannedWagonCutYards?.[wagon.id] ?? slot.alightYardId ?? schedule.destinationStationId; - await manager.getRepository(Wagon).update(wagon.id, { - currentTrainScheduleId: null, - trainSetWagonId: null, - // A wagon that belongs to a built train stays coupled to it (ASSIGNED); - // only loose wagons return to the open AVAILABLE pool. - status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, - currentYardId: settleYardId, - }); + const ownerTrainId = wagon.trainId; + const isRealCut = + (schedule.plannedWagonRealCuts ?? []).includes(wagon.id) && + schedule.plannedWagonCutYards?.[wagon.id] != null; + if (isRealCut && ownerTrainId) { + // Arrival fallback for a journey logged without mid-route + // checkpoints: the REAL cut still permanently removes the wagon + // from the built train at its cut yard. + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + trainId: null, + sequenceNumber: null, + importTrainNumber: null, + exportTrainNumber: null, + status: WagonStatus.Available, + currentYardId: settleYardId, + }); + await manager.query( + `UPDATE freight.train_set_wagons SET physical_wagon_id = NULL + WHERE physical_wagon_id = $1 + AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, + [wagon.id, ownerTrainId], + ); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: ownerTrainId, + action: 'REMOVE', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: settleYardId, + occurredAt: now, + }), + ); + } else { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + currentYardId: settleYardId, + }); + } // Ledger: the wagon rode this schedule to its settle yard. const slotAllocations = slot.allocations ?? []; await manager.getRepository(WagonMovement).save( @@ -4682,6 +4878,78 @@ export class TrainSchedulingService { ); } + // Planned couples: settle any that joined mid-route but have no pinned + // slot (the loop above never visits them), and — arrival fallback — + // join ones the checkpoint logs skipped: the train passed every stop, + // so a still-loose planned couple physically rode along. + const arrivalCouplePlan = schedule.plannedWagonCouples ?? {}; + const arrivalTrainId = schedule.trainSet?.trainId ?? null; + for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: coupleWagonId } }); + if (!wagon) continue; + if (wagon.currentTrainScheduleId === scheduleId) { + // Joined during the trip, slot-less: settle at the destination. + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + currentYardId: schedule.destinationStationId, + }); + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); + } else if ( + arrivalTrainId && + !wagon.trainId && + wagon.status === WagonStatus.Available && + !wagon.currentTrainScheduleId && + wagon.currentYardId === coupleYardId + ) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: arrivalTrainId }, + select: { id: true, sequenceNumber: true }, + }); + const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + await manager.getRepository(Wagon).update(wagon.id, { + trainId: arrivalTrainId, + sequenceNumber: maxSeq + 1, + status: WagonStatus.Assigned, + currentYardId: schedule.destinationStationId, + }); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: arrivalTrainId, + action: 'ADD', + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId: coupleYardId, + occurredAt: now, + }), + ); + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId, + trainScheduleId: scheduleId, + kind: WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); + } + } + // Ensure a destination checkpoint exists so the timeline shows ARRIVED. const stations = await this.buildScheduleStations(schedule); const finalStation = stations[stations.length - 1]; @@ -5177,7 +5445,7 @@ export class TrainSchedulingService { // above — the whole-route totals here are informational (summary) only. The // locomotive checks below also compare per edge: a train is never heavier // than its heaviest leg, so disjoint legs must not be summed. - const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops); + const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops, legByBookingId); const maxEdgeGrossTons = roundTons( Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)), ); @@ -5466,6 +5734,32 @@ export class TrainSchedulingService { return rows[0]?.planned_wagon_yards ?? {}; } + /** `{ wagonId: yardId }` this schedule cuts each wagon at; `{}` when unset. */ + private async plannedWagonCutYardsOf( + scheduleId: string | undefined, + ): Promise> { + if (!scheduleId) return {}; + const rows: { planned_wagon_cut_yards: Record | null }[] = + await this.dataSource.query( + `SELECT planned_wagon_cut_yards FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ); + return rows[0]?.planned_wagon_cut_yards ?? {}; + } + + /** `{ wagonId: pickupYardId }` of loose wagons this schedule plans to couple; `{}` when unset. */ + private async plannedWagonCouplesOf( + scheduleId: string | undefined, + ): Promise> { + if (!scheduleId) return {}; + const rows: { planned_wagon_couples: Record | null }[] = + await this.dataSource.query( + `SELECT planned_wagon_couples FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ); + return rows[0]?.planned_wagon_couples ?? {}; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, @@ -5673,8 +5967,12 @@ export class TrainSchedulingService { const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; - const plannedYards = pinSchedule?.plannedWagonYards ?? {}; + const couplePlan = pinSchedule?.plannedWagonCouples ?? {}; + // Couples ride into the yard plan as boarding entries: a slot boarding at + // the couple yard may pin the (still loose) planned couple wagon. + const plannedYards = { ...(pinSchedule?.plannedWagonYards ?? {}), ...couplePlan }; const cutPlan = pinSchedule?.plannedWagonCutYards ?? {}; + const coupleIds = new Set(Object.keys(couplePlan)); const unpinnable = this.findUnpinnableWagonSlots( planSlots, @@ -5686,6 +5984,7 @@ export class TrainSchedulingService { stops, plannedYards, cutPlan, + coupleIds, ); if (unpinnable.length) { throw new BadRequestException({ @@ -5710,6 +6009,7 @@ export class TrainSchedulingService { plannedYards, cutPlan, stops, + coupleIds, ); if (!physical) continue; @@ -5759,8 +6059,13 @@ export class TrainSchedulingService { builtTrainId, pinnedToScheduleIds, stops, - targetSchedule?.plannedWagonYards ?? {}, + // Couples count as boarding entries at their couple yard. + { + ...(targetSchedule?.plannedWagonYards ?? {}), + ...(targetSchedule?.plannedWagonCouples ?? {}), + }, targetSchedule?.plannedWagonCutYards ?? {}, + new Set(Object.keys(targetSchedule?.plannedWagonCouples ?? {})), ); } @@ -5795,6 +6100,7 @@ export class TrainSchedulingService { stops: string[] = [], plannedYards: PlannedWagonYards = {}, cutPlan: Record = {}, + coupleIds: Set = new Set(), ): string[] { const violations: string[] = []; // One physical wagon may serve several slots whose leg spans don't overlap @@ -5817,6 +6123,7 @@ export class TrainSchedulingService { plannedYards, cutPlan, stops, + coupleIds, ); if (!physical) { violations.push( @@ -5850,6 +6157,7 @@ export class TrainSchedulingService { plannedYards: PlannedWagonYards = {}, cutPlan: Record = {}, stops: string[] = [], + coupleIds: Set = new Set(), ): Wagon | undefined { // How far down the route a wagon rides before this schedule cuts it: // stop index of its cut yard, or the last stop when uncut (also when the @@ -5888,9 +6196,15 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + // A planned COUPLE (loose wagon joining at its couple yard) is pinnable + // alongside the train's own consist — its "boarding yard" is the couple + // yard, already merged into plannedYards by the callers. + const belongsToRun = (w: Wagon): boolean => + w.trainId === builtTrainId || + (coupleIds.has(w.id) && !w.trainId && w.status === WagonStatus.Available); const consistYards = new Set( wagons - .filter((w) => w.trainId === builtTrainId && scheduleYardOf(plannedYards, w)) + .filter((w) => belongsToRun(w) && scheduleYardOf(plannedYards, w)) .map((w) => scheduleYardOf(plannedYards, w) as string), ); // Split consist: a slot boarding at a given yard must take a wagon that @@ -5901,7 +6215,7 @@ export class TrainSchedulingService { const candidates = wagons .filter( (w) => - w.trainId === builtTrainId && + belongsToRun(w) && w.wagonTypeId === slot.wagonTypeId && spanFree(w.id) && // A wagon cut before the slot's alight stop cannot serve it. @@ -6076,16 +6390,19 @@ export class TrainSchedulingService { builtTrainId: string, scheduleId?: string, ): Promise { - const [wagons, plan] = await Promise.all([ + const [wagons, plan, cutPlan, couplePlan] = await Promise.all([ this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrainId }, relations: { wagonType: true }, }), this.plannedWagonYardsOf(scheduleId), + this.plannedWagonCutYardsOf(scheduleId), + this.plannedWagonCouplesOf(scheduleId), ]); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); const byYardId = new Map>(); + const cutWagons: NonNullable = []; for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, @@ -6099,6 +6416,38 @@ export class TrainSchedulingService { perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); byYardId.set(yardId, perType); } + // A wagon cut mid-route is not stock past its cut stop — consumers debit + // it per edge so "2 NW5 free from gmp" reads 1 when one is cut at Lebu. + const cutYardId = cutPlan[wagon.id]; + if (cutYardId) { + cutWagons.push({ wagonTypeId: wagon.wagonTypeId, poolYardId: yardId ?? '', cutYardId }); + } + } + // Planned couples: loose wagons joining the train mid-route are stock too, + // pooled at their couple yard so they serve bookings boarding there. + // ponytail: in multi-yard mode a couple serves only bookings boarding + // exactly at its couple yard (existing split-consist semantics) — + // conservative; upgrade = pool lookup falling back to the nearest pool + // at/before the leg's boarding edge. + const coupleIds = Object.keys(couplePlan); + if (coupleIds.length) { + const coupleWagons = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true }, + }); + for (const wagon of coupleWagons) { + // Already joined (or grabbed by another train) — counted via trainId then. + if (wagon.trainId) continue; + remainingByTypeId.set( + wagon.wagonTypeId, + (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, + ); + if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + const coupleYardId = couplePlan[wagon.id]; + const perType = byYardId.get(coupleYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(coupleYardId, perType); + } } // Single-yard consist (the overwhelming majority): the whole train is // offered at every boarding yard exactly as before — the per-yard split is @@ -6108,6 +6457,7 @@ export class TrainSchedulingService { remainingByTypeId, codesByTypeId, ...(byYardId.size > 1 ? { byYardId } : {}), + ...(cutWagons.length ? { cutWagons } : {}), }; } @@ -6568,11 +6918,20 @@ export class TrainSchedulingService { const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId)); const plan = schedule.plannedWagonYards ?? {}; const cutPlan = schedule.plannedWagonCutYards ?? {}; + const couplePlan = schedule.plannedWagonCouples ?? {}; + const realCuts = new Set(schedule.plannedWagonRealCuts ?? []); const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true, currentYard: true }, order: { sequenceNumber: 'ASC' }, }); + const coupleIds = Object.keys(couplePlan); + const coupleWagons = coupleIds.length + ? await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true, currentYard: true }, + }) + : []; const lockedIds = new Set( (schedule.trainSet?.wagons ?? []) .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) @@ -6580,7 +6939,7 @@ export class TrainSchedulingService { ); const offRouteYardIds = [ ...new Set( - wagons + [...wagons, ...coupleWagons] .flatMap((w) => [scheduleYardOf(plan, w), w.currentYardId]) .filter((y): y is string => !!y && !stops.some((s) => s.yardId === y)), ), @@ -6595,7 +6954,7 @@ export class TrainSchedulingService { const rows = wagons.map((w) => { const plannedYardId = scheduleYardOf(plan, w); - const cutYardId = cutPlan[w.id] ?? null; + const cutYardId: string | null = cutPlan[w.id] ?? null; const locked = lockedIds.has(w.id); return { id: w.id, @@ -6608,13 +6967,42 @@ export class TrainSchedulingService { physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null, plannedYardId, plannedYardLabel: plannedYardId ? labels.get(plannedYardId) ?? plannedYardId : null, - cutYardId, + cutYardId: cutYardId as string | null, cutYardLabel: cutYardId ? labels.get(cutYardId) ?? cutYardId : null, + realCut: realCuts.has(w.id), + coupledYardId: null as string | null, + coupledYardLabel: null as string | null, aligned: plannedYardId === w.currentYardId, locked, lockReason: locked ? 'Carries cargo booked on this schedule' : null, }; }); + // Planned couples: loose wagons joining mid-route, appended after the + // consist so the table reads consist-first. + for (const w of coupleWagons) { + const coupledYardId = couplePlan[w.id]; + const locked = lockedIds.has(w.id); + rows.push({ + id: w.id, + wagonNumber: w.wagonNumber, + sequenceNumber: null, + wagonType: w.wagonType + ? { id: w.wagonType.id, code: w.wagonType.code, name: w.wagonType.name } + : { id: w.wagonTypeId, code: w.wagonTypeId, name: w.wagonTypeId }, + physicalYardId: w.currentYardId, + physicalYardLabel: w.currentYardId ? labels.get(w.currentYardId) ?? w.currentYardId : null, + plannedYardId: null, + plannedYardLabel: null, + cutYardId: null, + cutYardLabel: null, + realCut: false, + coupledYardId, + coupledYardLabel: labels.get(coupledYardId) ?? coupledYardId, + aligned: w.currentYardId === coupledYardId, + locked, + lockReason: locked ? 'Carries cargo booked on this schedule' : null, + }); + } const perStop = stops.map((s) => ({ yardId: s.yardId, label: s.label, @@ -6622,6 +7010,7 @@ export class TrainSchedulingService { planned: rows.filter((r) => r.plannedYardId === s.yardId).length, physical: rows.filter((r) => r.physicalYardId === s.yardId).length, cut: rows.filter((r) => r.cutYardId === s.yardId).length, + coupled: rows.filter((r) => r.coupledYardId === s.yardId).length, })); return { scheduleId, @@ -6634,17 +7023,29 @@ export class TrainSchedulingService { } /** - * Re-plan which yard this departure boards wagons from (`yardId`) and/or - * where it cuts them mid-route (`cutYardId`; null clears — the wagon rides - * to the destination). Only DRAFT/SCHEDULED schedules, only the train's own - * wagons; boarding only at pickup stops, cutting only at drop stops after - * the boarding yard and never before allocated cargo's destination. + * Re-plan this departure's consist plan: boarding yard (`yardId`), cut yard + * (`cutYardId`; null clears), the `realCut` flag (permanent removal from the + * built train at the cut), and mid-route COUPLES of loose wagons + * (`couple`/`uncouple`). Only DRAFT/SCHEDULED schedules; boarding/coupling + * only at pickup stops, cutting only at drop stops after the boarding yard + * and never before allocated cargo's destination. Coupling validates every + * leg the new wagon rides against the locomotives' weight/length caps. * Physical yards are untouched — the train builder owns those. */ async updateScheduleWagonYards( scheduleId: string, - moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>, + dto: { + moves?: Array<{ + wagonId: string; + yardId?: string; + cutYardId?: string | null; + realCut?: boolean; + }>; + couple?: Array<{ wagonId: string; yardId: string }>; + uncouple?: string[]; + }, ) { + const moves = dto.moves ?? []; const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); const builtTrain = schedule.trainSet?.train; @@ -6665,7 +7066,7 @@ export class TrainSchedulingService { const pickupYardIds = new Set(stops.slice(0, -1).map((s) => s.yardId)); const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, - select: { id: true, currentYardId: true, wagonNumber: true }, + relations: { wagonType: true }, }); const wagonById = new Map(wagons.map((w) => [w.id, w])); const lockedIds = new Set( @@ -6693,6 +7094,8 @@ export class TrainSchedulingService { const plan: PlannedWagonYards = { ...(schedule.plannedWagonYards ?? {}) }; const cutPlan: Record = { ...(schedule.plannedWagonCutYards ?? {}) }; + const couplePlan: Record = { ...(schedule.plannedWagonCouples ?? {}) }; + const realCuts = new Set(schedule.plannedWagonRealCuts ?? []); for (const move of moves) { const wagon = wagonById.get(move.wagonId); if (!wagon) { @@ -6713,6 +7116,7 @@ export class TrainSchedulingService { } if (move.cutYardId === null) { delete cutPlan[wagon.id]; + realCuts.delete(wagon.id); } else if (move.cutYardId !== undefined) { if (!dropYardIds.has(move.cutYardId)) { throw new BadRequestException( @@ -6740,10 +7144,149 @@ export class TrainSchedulingService { ); } } + // Real-cut flag rides on the (now final) cut for this wagon. + if (move.realCut === false) { + realCuts.delete(wagon.id); + } else if (move.realCut === true) { + if (!cutPlan[wagon.id]) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber}: a real cut needs a cut yard — set where the wagon is cut first`, + ); + } + realCuts.add(wagon.id); + } } - await this.dataSource - .getRepository(TrainSchedule) - .update(scheduleId, { plannedWagonYards: plan, plannedWagonCutYards: cutPlan }); + // A flag whose cut disappeared (any path) must not survive. + for (const id of [...realCuts]) if (!cutPlan[id]) realCuts.delete(id); + + // ── Couples: loose wagons planned to join the train at a pickup stop ── + const uncouple = new Set(dto.uncouple ?? []); + for (const id of uncouple) { + if (!couplePlan[id]) { + throw new BadRequestException(`Wagon ${id} is not in this schedule's couple plan`); + } + if (lockedIds.has(id)) { + throw new ConflictException( + 'Coupled wagon carries cargo booked on this schedule — free the bookings first', + ); + } + delete couplePlan[id]; + } + const coupleEntries = dto.couple ?? []; + if (new Set(coupleEntries.map((c) => c.wagonId)).size !== coupleEntries.length) { + throw new BadRequestException('A wagon appears more than once in the couple list'); + } + for (const c of coupleEntries) { + if (uncouple.has(c.wagonId)) { + throw new BadRequestException('A wagon cannot be both coupled and uncoupled in one save'); + } + if (!pickupYardIds.has(c.yardId)) { + throw new BadRequestException( + `Yard ${c.yardId} is not a pickup stop of this schedule's route`, + ); + } + if (wagonById.has(c.wagonId)) { + throw new BadRequestException( + `Wagon is already in train ${builtTrain.code}'s consist — use its yard/cut controls instead`, + ); + } + couplePlan[c.wagonId] = c.yardId; + } + if (coupleEntries.length) { + const incoming = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleEntries.map((c) => c.wagonId)) }, + relations: { wagonType: true }, + }); + const incomingById = new Map(incoming.map((w) => [w.id, w])); + const pinnedElsewhere = await this.wagonIdsPinnedToLiveSchedules(undefined, builtTrain.id); + for (const c of coupleEntries) { + const w = incomingById.get(c.wagonId); + if (!w) throw new BadRequestException(`Wagon ${c.wagonId} not found`); + if (w.trainId) { + throw new ConflictException( + `Wagon ${w.wagonNumber} is already coupled to another built train`, + ); + } + if (w.status !== WagonStatus.Available) { + throw new ConflictException(`Wagon ${w.wagonNumber} is not available (${w.status})`); + } + if (w.currentYardId !== c.yardId) { + throw new BadRequestException( + `Wagon ${w.wagonNumber} does not stand at the couple yard — it must physically wait where the train picks it up`, + ); + } + if (pinnedElsewhere.has(w.id)) { + throw new ConflictException( + `Wagon ${w.wagonNumber} is reserved by another live schedule`, + ); + } + } + } + + // ── Per-leg weight/length guard: a couple must fit every edge it rides ── + // Cuts alone only shrink load; the guard runs whenever couples remain in + // the final plan, so cut-then-couple in one save passes on the freed edge. + const coupleIds = Object.keys(couplePlan); + if (coupleIds.length) { + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const pullCap = + (limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0); + const lenCap = + (limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0); + const edgeCount = Math.max(1, stops.length - 1); + const coupleWagons = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(coupleIds) }, + relations: { wagonType: true }, + }); + const spanOfConsist = (w: Wagon) => ({ + fromEdge: stopIdx.get(scheduleYardOf(plan, w) ?? '') ?? 0, + toEdge: cutPlan[w.id] ? stopIdx.get(cutPlan[w.id]) ?? edgeCount : edgeCount, + tareTons: Number(w.wagonType?.tareWeightTons ?? 0), + lengthMeters: Number(w.wagonType?.lengthMeters ?? 0), + }); + const wagonSpans = [ + ...wagons.map(spanOfConsist), + ...coupleWagons.map((w) => ({ + fromEdge: stopIdx.get(couplePlan[w.id]) ?? 0, + toEdge: edgeCount, + tareTons: Number(w.wagonType?.tareWeightTons ?? 0), + lengthMeters: Number(w.wagonType?.lengthMeters ?? 0), + })), + ]; + const cargoLegs = (schedule.trainSet?.wagons ?? []).flatMap((slot) => + (slot.allocations ?? []).flatMap((alloc) => { + if (!alloc.booking) return []; + return [ + { + fromEdge: stopIdx.get(alloc.booking.originYardId) ?? 0, + toEdge: stopIdx.get(alloc.booking.destinationYardId) ?? edgeCount, + weightTons: Number(alloc.allocatedWeightTons ?? 0), + }, + ]; + }), + ); + const loads = computeEdgeLoads(edgeCount, wagonSpans, cargoLegs); + for (let e = 0; e < edgeCount; e += 1) { + const legLabel = `${stops[e].label} → ${stops[e + 1].label}`; + if (pullCap > 0 && loads[e].weightTons > pullCap) { + throw new BadRequestException( + `Leg ${legLabel}: coupling puts gross weight at ${Math.round(loads[e].weightTons)}T, over the locomotives' ${Math.round(pullCap)}T limit — cut a wagon riding this leg first (real cut frees the train permanently)`, + ); + } + if (lenCap > 0 && loads[e].lengthMeters > lenCap) { + throw new BadRequestException( + `Leg ${legLabel}: coupling puts train length at ${Math.round(loads[e].lengthMeters)}m, over the ${Math.round(lenCap)}m limit — cut a wagon riding this leg first`, + ); + } + } + } + + await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { + plannedWagonYards: plan, + plannedWagonCutYards: cutPlan, + plannedWagonCouples: couplePlan, + plannedWagonRealCuts: [...realCuts], + }); // ponytail: per-stop over-booking check counts bookings boarding at the // stop against wagons planned there, ignoring leg sharing — a warning, not @@ -7535,6 +8078,10 @@ export class TrainSchedulingService { trainSetWagonId: null, currentTrainScheduleId: null, currentYardId, + // A wagon leaving the build sheds its run numbers, same as the train + // builder's removeWagon — they belong to the train, not the wagon. + importTrainNumber: null, + exportTrainNumber: null, }; for (const wagon of removed) { await manager.getRepository(Wagon).update(wagon.id, detachPatch); @@ -8171,8 +8718,10 @@ export class TrainSchedulingService { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }); - // Wagons staff plan to cut mid-route are gone from every edge past the cut. + // Wagons staff plan to cut mid-route are gone from every edge past the + // cut; planned couples add a slot from their couple stop onward. subtractCutWagons(budget, schedule.plannedWagonCutYards); + addCoupledWagons(budget, schedule.plannedWagonCouples); for (const sb of schedule.scheduleBookings ?? []) { if (!sb.booking) continue; budget.subtract( @@ -8845,6 +9394,24 @@ export class TrainSchedulingService { // enforcement; coupled-but-empty consist wagons ride every edge. const heaviestLeg = schedule.trainSet ? (() => { + const legStops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const legStopIdx = new Map(legStops.map((yardId, i) => [yardId, i])); + // Booking id → the stop-index span its cargo actually rides. Without + // this map a shared slot's FULL cargo counts on every edge the slot + // spans, over-reporting the heaviest leg (S-2026-00045 read 3703T on + // a leg that truly carried 2905T). Unknown yards fall back to the + // slot's whole span inside slotCargoOnEdge — conservative, as before. + const legByBookingId = new Map(); + for (const slot of schedule.trainSet.wagons ?? []) { + for (const alloc of slot.allocations ?? []) { + const booking = alloc.booking; + if (!booking || legByBookingId.has(alloc.bookingId)) continue; + legByBookingId.set(alloc.bookingId, { + from: legStopIdx.get(booking.originYardId) ?? -1, + to: legStopIdx.get(booking.destinationYardId) ?? -1, + }); + } + } const usage = maxEdgeConsistUsage( [ ...(schedule.trainSet.wagons ?? []).map((w) => ({ @@ -8864,7 +9431,8 @@ export class TrainSchedulingService { allocations: [], })), ], - this.mapScheduleStops(schedule).map((s) => s.yardId), + legStops, + legByBookingId, ); return { grossWeightTons: roundTons(usage.grossWeightTons), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index bac2db62a..a9741b476 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -14,6 +14,7 @@ import { sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, + validateMixedTrainLimitsPerEdge, validateWagonCargoExclusivity, } from './wagon-plan.util'; @@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () loadedWagonCount: 2, }); }); + + it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => { + // One wagon reused across legs: booking X rides a→b (40T), booking Y + // boards at b with 30T. The slot spans the whole route, but edge a→b + // must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges. + const shared = { + tareWeightTons: 24, + assignedWeightTons: 70, + lengthMeters: 14, + boardYardId: null, + alightYardId: null, + allocations: [ + { bookingId: 'X', allocatedWeightTons: 40 }, + { bookingId: 'Y', allocatedWeightTons: 30 }, + ], + } as never; + const legs = new Map([ + ['X', { from: 0, to: 1 }], + ['Y', { from: 1, to: 2 }], + ]); + // Without legs: whole-span scalar on both edges (94T binding edge). + expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94); + // With legs: heaviest edge is a→b at 64T (b→c is 54T). + expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64); + }); + + it('falls back to the whole-span scalar when an allocation has no readable weight', () => { + const shared = { + tareWeightTons: 24, + assignedWeightTons: 70, + lengthMeters: 14, + boardYardId: null, + alightYardId: null, + allocations: [{ bookingId: 'X' }], + } as never; + const legs = new Map([['X', { from: 0, to: 1 }]]); + expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94); + }); +}); + +describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => { + it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => { + // 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y + // boards at b with 25T/wagon. Whole-span scalars read every edge as + // 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and + // 90T (b→c) — both fit. + const slot = (seq: number) => ({ + sequenceNo: seq, + wagonTypeId: 'wt-nw5', + wagonTypeCode: 'NW5', + capacityTons: 70, + lengthMeters: 14, + tareWeightTons: 20, + assignedWeightTons: 55, + boardYardId: null, + alightYardId: null, + allocations: [ + { + bookingId: 'X', + bookingReference: 'X', + allocatedWeightTons: 30, + loadType: AllocationLoadType.Container, + }, + { + bookingId: 'Y', + bookingReference: 'Y', + allocatedWeightTons: 25, + loadType: AllocationLoadType.Container, + }, + ], + }); + const legs = new Map([ + ['X', { from: 0, to: 1 }], + ['Y', { from: 1, to: 2 }], + ]); + const run = (withLegs?: typeof legs) => + validateMixedTrainLimitsPerEdge( + [slot(1), slot(2)] as never, + [{ lengthMeters: 14 }], + { maxWeightTons: 100 }, + ['a', 'b', 'c'], + undefined, + withLegs, + ); + expect(run()).toHaveLength(2); // both edges falsely overweight without legs + expect(run(legs)).toHaveLength(0); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index cf2c384d9..2df626a84 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -654,9 +654,16 @@ export function validateMixedTrainLimitsPerEdge( const label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); for (let edge = 0; edge < stops.length - 1; edge += 1) { - const active = wagonPlan.filter( - (_, i) => spans[i].from <= edge && edge < spans[i].to, - ); + // A shared slot rides the UNION of its cargo legs, but only carries each + // booking's cargo on that booking's own edges — weigh the edge with the + // cargo actually aboard there, not the slot's whole-route scalar, or a + // container boarding at Dire Dawa reads as hauled from Djibouti. + const active = wagonPlan + .filter((_, i) => spans[i].from <= edge && edge < spans[i].to) + .map((slot) => ({ + ...slot, + assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs), + })); if (!active.length) continue; for (const violation of validateMixedTrainLimits( active, @@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick< allocations?: unknown[]; }; +/** + * Cargo tons a slot actually carries on one edge. With a legs map and readable + * allocation records, each booking's cargo counts only on the edges that + * booking rides (an unmapped booking stays on the slot's whole span). Without + * either — or when any allocation lacks a numeric weight, e.g. persisted rows + * fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span + * `assignedWeightTons`, the pre-existing reading. + */ +function slotCargoOnEdge( + slot: EdgeUsageSlot, + edge: number, + edgeCount: number, + legs?: Map, +): number { + const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0); + const allocations = (slot.allocations ?? []) as Array<{ + bookingId?: string; + allocatedWeightTons?: number | string; + }>; + if (!legs?.size || !allocations.length) return wholeSpanCargo; + let cargo = 0; + for (const allocation of allocations) { + const weight = Number(allocation?.allocatedWeightTons); + if (!Number.isFinite(weight)) return wholeSpanCargo; + const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined; + const rides = + !leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to + ? true + : leg.from <= edge && edge < leg.to; + if (rides) cargo += weight; + } + return cargo; +} + /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ function slotSpans( wagonPlan: EdgeUsageSlot[], @@ -708,8 +749,10 @@ function slotSpans( export function maxEdgeConsistUsage( wagonPlan: EdgeUsageSlot[], stops: string[], + /** Booking id → stop-index span; cargo then weighs only its own edges. */ + legs?: Map, ): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { - return perEdgeConsistUsage(wagonPlan, stops).reduce( + return perEdgeConsistUsage(wagonPlan, stops, legs).reduce( (max, e) => ({ grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), @@ -737,12 +780,19 @@ export type EdgeConsistUsage = { export function perEdgeConsistUsage( wagonPlan: EdgeUsageSlot[], stops: string[], + /** + * Booking id → stop-index span. When given, a shared slot's cargo weighs + * only the edges its booking rides (tare still rides the slot's whole + * span) — without it a slot's full cargo counts on every edge it spans. + */ + legs?: Map, ): EdgeConsistUsage[] { + const edgeCount = Math.max(1, stops.length - 1); const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ edge, grossWeightTons: slots.reduce( (sum, w) => - sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), + sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs), 0, ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 2ed5625c6..449033bfc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -54,6 +54,13 @@ export type WagonStock = { * math. */ byYardId?: Map>; + /** + * Wagons the schedule CUTS mid-route (staff plan): each is stock only up to + * its cut stop. Consumers debit it from its pool on every edge at/after the + * cut, so a leg riding past the cut never counts it. Absent = no cuts. + * `poolYardId` is the wagon's boarding pool ('' on a single-yard consist). + */ + cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>; }; export type FlexPlanResult = { @@ -324,6 +331,16 @@ export function planWagonsWithStock(params: { } return row; }; + // Cut wagons are pre-consumed on every edge at/after their cut stop: they + // are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops + // given / off-corridor) is skipped — conservative, same as before cuts. + for (const cut of stock.cutWagons ?? []) { + const fromEdge = stops.indexOf(cut.cutYardId); + if (fromEdge < 0) continue; + const pool = stock.byYardId ? cut.poolYardId : ''; + const row = usedRow(rowKeyFor(cut.wagonTypeId, pool)); + for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1; + } const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { const pool = poolOf(leg); const total = totalFor(wagonTypeId, pool); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 5815ce435..a1d96e8e9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => { expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); }); }); + +describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => { + // gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp + // (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp. + const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct']; + const makeLedger = () => { + const ledger = new WagonStockLedger( + new Map([ + ['nw5', 3], + ['pw2', 2], + ]), + stops.length - 1, + new Map([ + ['gmp', new Map([['nw5', 2], ['pw2', 2]])], + ['mojo', new Map([['nw5', 1]])], + ]), + stops, + ); + ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]); + return ledger; + }; + const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to }); + + it('a leg past the cut sees only the wagons that reach it', () => { + const ledger = makeLedger(); + // gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through. + expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1); + // gmp -> lebu: both gmp NW5 serve the short leg. + expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2); + // PW2 uncut — both ride anywhere from gmp. + expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2); + // mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut. + expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1); + }); + + it('cut debit and booking consumption stack', () => { + const ledger = makeLedger(); + expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1); + expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0); + // Short leg still has the cut wagon (1 = 2 total − 1 consumed through-rider). + expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1); + }); + + it('ignores a cut yard that is not on the stops', () => { + const ledger = makeLedger(); + ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]); + expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index bf5b935ad..051d40c07 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -75,6 +75,32 @@ export class WagonStockLedger { return Math.max(0, total - busiest); } + /** + * Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its + * pool's stock on every edge at/after its cut stop, so a leg riding past the + * cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu). + * A cut yard not on this ledger's stops is skipped — conservative, matches + * the pre-cut behavior. + */ + debitCutWagons( + cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>, + ): void { + for (const cut of cuts) { + const fromEdge = this.stops.indexOf(cut.cutYardId); + if (fromEdge < 0) continue; + const pool = this.byYardId ? cut.poolYardId : ''; + const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId; + let row = this.usedPerEdge.get(key); + if (!row) { + row = new Array(this.edgeCount).fill(0); + this.usedPerEdge.set(key, row); + } + for (let edge = fromEdge; edge < this.edgeCount; edge += 1) { + row[edge] = (row[edge] ?? 0) + 1; + } + } + } + /** * Free wagons across every type a booking may ride. A cargo/container type * mapped to several wagon types can use any of them, so they add up. 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 dcaf81d4b..21431a1a4 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 @@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -78,6 +79,24 @@ export class TrainBuilderController { return this.trainBuilderService.getComposition(id); } + @Get(':id/history') + @ApiOperation({ + summary: + "Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike", + }) + history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) { + return this.trainBuilderService.getTrainHistory(id, query); + } + + @Get(':id/detached-wagons') + @ApiOperation({ + summary: + 'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach', + }) + detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) { + return this.trainBuilderService.getDetachedWagons(id, query); + } + @Put(':id/locomotives') @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) 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 beac844e4..d7cfc29c5 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 @@ -231,6 +231,117 @@ export class TrainBuilderService { return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule])); } + /** + * Wagon adjustment history of one built train, newest first: builder + * attaches/detaches (no schedule) and trip events (real cuts, couples, + * consist adjustments — carrying their schedule reference) alike. + */ + async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) { + const { page, pageSize, skip, take } = normalizePagination(query); + const [countRows, rows]: [ + Array<{ total: string }>, + Array<{ + id: string; + action: string; + subject: string; + yardLabel: string | null; + actor: string | null; + scheduleReference: string | null; + occurredAt: Date; + }>, + ] = await Promise.all([ + this.dataSource.query( + `SELECT count(*) AS total + FROM freight.schedule_wagon_adjustment_logs l + WHERE l.train_id = $1 + AND l.deleted_at IS NULL`, + [trainId], + ), + this.dataSource.query( + `SELECT l.id, + l.action, + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + ts.reference AS "scheduleReference", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id + WHERE l.train_id = $1 + AND l.deleted_at IS NULL + ORDER BY l.occurred_at DESC + LIMIT $2 OFFSET $3`, + [trainId, take, skip], + ), + ]); + const total = Number(countRows[0]?.total ?? 0); + return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; + } + + /** + * Wagons last detached from THIS train that are still loose (no train, + * AVAILABLE) — the re-attach shortlist, with when/where/by whom each was + * last detached. Derived from the adjustment log, no denormalized column. + */ + async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) { + const { page, pageSize, skip, take } = normalizePagination(query); + const lastRemovalSql = ` + SELECT DISTINCT ON (l.wagon_id) + l.wagon_id AS "wagonId", + l.occurred_at AS "detachedAt", + COALESCE(y.label, y.code) AS "detachedYardLabel", + COALESCE(u.username, u.email) AS "detachedBy" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_id = $1 + AND l.action = 'REMOVE' + AND l.deleted_at IS NULL + ORDER BY l.wagon_id, l.occurred_at DESC`; + const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`; + const [countRows, rows]: [ + Array<{ total: string }>, + Array<{ + wagonId: string; + wagonNumber: string; + wagonTypeCode: string | null; + currentYardLabel: string | null; + detachedAt: Date; + detachedYardLabel: string | null; + detachedBy: string | null; + }>, + ] = await Promise.all([ + this.dataSource.query( + `SELECT count(*) AS total + FROM (${lastRemovalSql}) last_removal + JOIN freight.wagons w ON w.id = last_removal."wagonId" + WHERE ${stillLoose}`, + [trainId], + ), + this.dataSource.query( + `SELECT last_removal."wagonId", + w.wagon_number AS "wagonNumber", + wt.code AS "wagonTypeCode", + COALESCE(cy.label, cy.code) AS "currentYardLabel", + last_removal."detachedAt", + last_removal."detachedYardLabel", + last_removal."detachedBy" + FROM (${lastRemovalSql}) last_removal + JOIN freight.wagons w ON w.id = last_removal."wagonId" + LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id + WHERE ${stillLoose} + ORDER BY last_removal."detachedAt" DESC + LIMIT $2 OFFSET $3`, + [trainId, take, skip], + ), + ]); + const total = Number(countRows[0]?.total ?? 0); + return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; + } + /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ async getComposition(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ @@ -721,6 +832,7 @@ export class TrainBuilderService { toYardId: yardId, kind: WagonMovementKind.Maintenance, note: notes.movementNote, + movedByUserId: userId, occurredAt: new Date(), }), ); @@ -1097,15 +1209,14 @@ export class TrainBuilderService { .getRepository(TrainSet) .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); } - if (!schedule) return null; - - await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); - + // Log the consist change even when the train has no live schedule — the + // builder's own detach/attach is the train's history too (who removed + // which wagon, when, where), and the detached-wagons tab reads it back. const now = new Date(); await manager.getRepository(ScheduleWagonAdjustmentLog).save( changes.map((c) => manager.getRepository(ScheduleWagonAdjustmentLog).create({ - trainScheduleId: schedule.id, + trainScheduleId: schedule?.id ?? null, trainId, action: c.action, wagonId: c.wagonId, @@ -1117,6 +1228,10 @@ export class TrainBuilderService { ), ); + if (!schedule) return null; + + await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); + // The FULL/reopen decision must run AFTER the transaction commits — see // reconcileWindowAfterConsistChange. return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx new file mode 100644 index 000000000..72428c329 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx @@ -0,0 +1,194 @@ +import { + Badge, + Button, + Checkbox, + Group, + Pagination, + Paper, + Stack, + Table, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Link2, MapPin, PackageOpen, User } from "lucide-react"; +import { useState } from "react"; + +import { api } from "@/services/api"; + +interface Props { + trainId: string; + /** Staff may attach and the train is editable (not out on a run). */ + canAttach: boolean; + attachPending: boolean; + onAttach: (wagonIds: string[]) => void; +} + +/** + * "Detached wagons" tab: wagons last detached from THIS train that are still + * loose — with when, where and by whom they were detached — so staff can pick + * them straight back onto the consist without hunting through the global pool. + */ +export default function DetachedWagonsPanel({ + trainId, + canAttach, + attachPending, + onAttach, +}: Props) { + const [page, setPage] = useState(1); + const query = useQuery( + api.trainBuilder.detachedWagons.queryOptions({ + input: { id: trainId, page, pageSize: 20 }, + enabled: Boolean(trainId), + // Keep the previous page on screen while the next one loads. + placeholderData: (prev) => prev, + }), + ); + const rows = query.data?.items ?? []; + const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1); + // Selection is page-scoped in the header checkbox but survives paging, so + // staff can gather wagons across pages into one attach. + const [selected, setSelected] = useState>(new Set()); + const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId)); + + const toggle = (wagonId: string, checked: boolean) => + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(wagonId); + else next.delete(wagonId); + return next; + }); + + return ( + + + + + + + + + + Detached wagons + + + Wagons that left this train and are still loose — select and + attach them back in one click. + + + + {canAttach ? ( + + ) : null} + + + {query.isLoading ? ( + + Loading detached wagons… + + ) : rows.length === 0 ? ( + + No loose wagons were detached from this train — detach history starts + being recorded from now on. + + ) : ( + + + + {canAttach ? ( + + 0 && !allSelected} + onChange={(e) => + setSelected( + e.currentTarget.checked + ? new Set(rows.map((r) => r.wagonId)) + : new Set(), + ) + } + /> + + ) : null} + Wagon + Type + Now standing at + Last detached + + + + {rows.map((r) => ( + + {canAttach ? ( + + toggle(r.wagonId, e.currentTarget.checked)} + /> + + ) : null} + + + {r.wagonNumber} + + + + + {r.wagonTypeCode ?? "—"} + + + + {r.currentYardLabel ?? "No yard"} + + + + + {new Date(r.detachedAt).toLocaleDateString()} + + {r.detachedYardLabel ? ( + + + + at {r.detachedYardLabel} + + + ) : null} + {r.detachedBy ? ( + + + + by {r.detachedBy} + + + ) : null} + + + + ))} + +
+ )} + + {totalPages > 1 ? ( + + + {query.data?.meta.total ?? 0} wagon(s) · selection carries across pages + + + + ) : null} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx new file mode 100644 index 000000000..ec8b468a6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainHistoryPanel.tsx @@ -0,0 +1,140 @@ +import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react"; +import { useState } from "react"; + +import { api } from "@/services/api"; +import type { TrainHistoryEntry } from "@/services/trainBuilder.service"; + +const PAGE_SIZE = 20; + +const ACTION_META: Record< + TrainHistoryEntry["action"], + { label: string; color: string; icon: typeof Plus } +> = { + ADD: { label: "Wagon attached", color: "edr-green", icon: Plus }, + REMOVE: { label: "Wagon detached", color: "red", icon: Minus }, + SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight }, +}; + +/** + * "History" tab of the train-builder detail page: every wagon ever attached, + * detached or switched on this built train — builder edits and trip events + * (real cuts, mid-route couples, consist adjustments) alike, newest first. + */ +export default function TrainHistoryPanel({ trainId }: { trainId: string }) { + const [page, setPage] = useState(1); + const historyQuery = useQuery( + api.trainBuilder.history.queryOptions({ + input: { id: trainId, page, pageSize: PAGE_SIZE }, + enabled: Boolean(trainId), + // Keep the previous page on screen while the next one loads. + placeholderData: (prev) => prev, + }), + ); + const entries = historyQuery.data?.items ?? []; + const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1); + const total = historyQuery.data?.meta.total ?? 0; + + return ( + + + + + + + + + Wagon history + + + Who attached, detached or switched which wagon on this train — from + the builder and from its trips — newest first. + + + + + {historyQuery.isLoading ? ( + + Loading history… + + ) : entries.length === 0 ? ( + + No wagon changes recorded yet for this train. + + ) : ( + + {entries.map((entry) => { + const meta = ACTION_META[entry.action] ?? ACTION_META.ADD; + const Icon = meta.icon; + return ( + } + color={meta.color} + title={ + + + {meta.label} + + {entry.subject ? ( + + {entry.subject} + + ) : null} + {entry.scheduleReference ? ( + } + > + {entry.scheduleReference} + + ) : ( + + Builder + + )} + + } + > + + + {new Date(entry.occurredAt).toLocaleString()} + + {entry.yardLabel ? ( + + + + at {entry.yardLabel} + + + ) : null} + {entry.actor ? ( + + + + {entry.actor} + + + ) : null} + + + ); + })} + + )} + + {totalPages > 1 ? ( + + + {total} change(s) + + + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx index b09cf2a37..1d60efdf2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWagonYardPanel.tsx @@ -2,20 +2,27 @@ import { Alert, Badge, Button, + Checkbox, Group, Loader, + Modal, NumberInput, + Pagination, Paper, + ScrollArea, Select, SimpleGrid, Stack, Table, Text, + TextInput, Tooltip, } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { Freight } from "@edr/types"; import { isAxiosError } from "axios"; -import { AlertTriangle, Lock, MapPin } from "lucide-react"; +import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react"; import { useMemo, useState } from "react"; import { useToast } from "@/hooks/use-toast"; @@ -56,6 +63,21 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { const [pending, setPending] = useState>({}); /** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */ const [pendingCut, setPendingCut] = useState>({}); + /** wagonId → real-cut flag queued but not yet saved. */ + const [pendingRealCut, setPendingRealCut] = useState>({}); + /** Loose wagons queued to couple: wagonId → couple stop + display data. */ + const [pendingCouples, setPendingCouples] = useState< + Record + >({}); + /** Already-planned couples queued for removal. */ + const [pendingUncouple, setPendingUncouple] = useState([]); + // "Add wagon" modal + its filters. + const [coupleModalOpen, setCoupleModalOpen] = useState(false); + const [coupleYardFilter, setCoupleYardFilter] = useState(null); + const [coupleType, setCoupleType] = useState(null); + const [coupleSearch, setCoupleSearch] = useState(""); + const [couplePage, setCouplePage] = useState(1); + const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300); const [bulkType, setBulkType] = useState(null); const [bulkFrom, setBulkFrom] = useState(null); const [bulkTo, setBulkTo] = useState(null); @@ -63,6 +85,39 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { const editable = Boolean(canEdit && data?.editable); const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]); + /** Mid-route stops only — wagons are coupled between the origin and the destination. */ + const intermediateStops = useMemo(() => { + const stops = data?.stops ?? []; + return stops.slice(1, -1).filter((s) => s.pickup); + }, [data]); + // Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled + // where it physically stands, and only at a pickup stop of this route — the + // Add button carries that yard; off-route wagons render disabled. + const coupleListQuery = useQuery( + api.wagons.listPaged.queryOptions({ + input: { + filters: { + status: Freight.WagonStatus.Available, + unassigned: true, + currentYardId: coupleYardFilter ?? undefined, + wagonTypeId: coupleType ?? undefined, + search: debouncedCoupleSearch || undefined, + page: couplePage, + pageSize: 8, + }, + }, + enabled: editable && coupleModalOpen, + placeholderData: (prev) => prev, + }), + ); + const coupleCandidates = coupleListQuery.data?.items ?? []; + const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1); + const yardsQuery = useQuery( + api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }), + ); + const wagonTypesQuery = useQuery( + api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }), + ); const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label })); const yardLabel = (id: string | null) => (data?.stops ?? []).find((s) => s.yardId === id)?.label ?? @@ -74,6 +129,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId; const effectiveCut = (w: ScheduleWagonYardRow) => w.id in pendingCut ? pendingCut[w.id] : w.cutYardId; + const effectiveRealCut = (w: ScheduleWagonYardRow) => + (pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null; const stopIndexOf = (yardId: string | null) => yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId); /** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after @@ -108,8 +165,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { cut: (data?.wagons ?? []).filter( (w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId, ).length, + coupled: + (data?.wagons ?? []).filter( + (w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id), + ).length + + Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length, })), - [data, pending, pendingCut], + [data, pending, pendingCut, pendingCouples, pendingUncouple], ); const typeOptions = useMemo(() => { const seen = new Map(); @@ -117,7 +179,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { return [...seen].map(([value, label]) => ({ value, label })); }, [data]); - const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size; + const pendingCount = + new Set([ + ...Object.keys(pending), + ...Object.keys(pendingCut), + ...Object.keys(pendingRealCut), + ]).size + + Object.keys(pendingCouples).length + + pendingUncouple.length; const queueBulk = () => { if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return; @@ -152,7 +221,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { const handleSave = async () => { if (!pendingCount) return; try { - const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])]; + const wagonIds = [ + ...new Set([ + ...Object.keys(pending), + ...Object.keys(pendingCut), + ...Object.keys(pendingRealCut), + ]), + ]; const result = await save.mutateAsync({ scheduleId, payload: { @@ -160,11 +235,24 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { wagonId, ...(wagonId in pending ? { yardId: pending[wagonId] } : {}), ...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}), + ...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}), })), + ...(Object.keys(pendingCouples).length + ? { + couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({ + wagonId, + yardId: c.yardId, + })), + } + : {}), + ...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}), }, }); setPending({}); setPendingCut({}); + setPendingRealCut({}); + setPendingCouples({}); + setPendingUncouple([]); toast({ title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`, description: result.warnings.length ? result.warnings.join(" ") : undefined, @@ -197,8 +285,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { Planned = where this departure boards the wagon (what customers can book per origin). Physical = where the wagon stands now (train builder). Cut at ={" "} where this departure detaches the wagon and leaves it — blank means it rides to the - destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until - every wagon stands at its planned yard. + destination; booking capacity past the cut shrinks accordingly. Tick Real cut to + remove the wagon from the train build permanently at that yard (untick = it sits out this + trip only). Coupled wagons are loose wagons joining the train at a stop — they + become part of the build for good. Dispatch is blocked until every wagon stands at its + planned yard. {data.misaligned > 0 ? ( {" "} @@ -232,9 +323,19 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { Cut {s.cut} ) : null} + {s.coupled > 0 ? ( + + +{s.coupled} coupled + + ) : null} {!s.pickup ? ( - Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)} + Through{" "} + {data.wagons.filter( + (w) => !w.coupledYardId || !pendingUncouple.includes(w.id), + ).length + + Object.keys(pendingCouples).length - + perStop.reduce((sum, p) => sum + p.cut, 0)} ) : null} @@ -275,6 +376,214 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { ) : null} + {editable ? ( + + + + + Consist plan for this trip + + + + + ) : null} + + setCoupleModalOpen(false)} + size="xl" + radius="md" + title={ + + + Add wagons to this trip + + } + > + + + A wagon is coupled where it physically stands, so it must be waiting at one of this + route's stops between the origin and the destination. Wagons elsewhere are listed + but cannot be added until they are moved. + + + ({ + value: t.id, + label: t.code ? `${t.name} (${t.code})` : t.name, + }))} + value={coupleType} + onChange={(v) => { + setCoupleType(v); + setCouplePage(1); + }} + w={200} + /> + } + value={coupleSearch} + onChange={(e) => { + setCoupleSearch(e.currentTarget.value); + setCouplePage(1); + }} + w={200} + /> + + {coupleListQuery.isLoading ? ( + + + + ) : ( + + + + + Wagon + Type + Standing at + Couple + + + + {coupleCandidates.map((w) => { + const onTrip = data.wagons.some((row) => row.id === w.id); + const queued = w.id in pendingCouples; + const stop = intermediateStops.find((s) => s.yardId === w.currentYardId); + return ( + + + + {w.wagonNumber} + + + + + {w.wagonType?.code ?? w.wagonTypeId} + + + + {w.currentYard?.label ?? "No yard"} + + + {onTrip ? ( + + On this trip + + ) : queued ? ( + + ) : stop ? ( + + ) : ( + + + + )} + + + ); + })} + {coupleCandidates.length === 0 ? ( + + + + No loose wagons match the filters. + + + + ) : null} + +
+
+ )} + + {coupleTotalPages > 1 ? ( + + ) : ( + + )} + + + {Object.keys(pendingCouples).length} wagon(s) queued — save the plan to apply + + + + +
+
+ @@ -288,88 +597,218 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { - {data.wagons.map((w) => { - const planned = effectiveYard(w); - const cut = effectiveCut(w); - const changed = w.id in pending || w.id in pendingCut; - return ( - - {w.sequenceNumber ?? "—"} - - - {w.wagonNumber} - - - {w.wagonType.code} - {w.physicalYardLabel ?? "No yard"} - - {editable && !w.locked ? ( - { + setPending((prev) => { + const next = { ...prev }; + if (!v || v === w.plannedYardId) delete next[w.id]; + else next[w.id] = v; + return next; + }); + setPendingCut((prev) => + clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId), + ); + }} + w={180} + /> + ) : ( + + {yardLabel(planned)} + {w.locked ? ( + + + + ) : null} + + )} + + + {editable ? ( + // Locked wagons stay editable here — the server enforces the + // cargo-destination floor and the toast explains a 409. + + - setPendingCut((prev) => { - const next = { ...prev }; - if ((v ?? null) === w.cutYardId) delete next[w.id]; - else next[w.id] = v ?? null; - return next; - }) - } - w={180} - /> - ) : ( - {cut ? yardLabel(cut) : "Destination"} - )} - - - {planned === w.physicalYardId ? ( - - Aligned - - ) : ( - - Needs move - - )} - - - ); - })} + + + ); + })} + {Object.entries(pendingCouples).map(([wagonId, c]) => ( + + + + + {c.wagonNumber} + + + {c.typeCode} + {yardLabel(c.yardId)} + + }> + Coupled at {yardLabel(c.yardId)} (pending) + + + + Destination + + + + + + ))}
@@ -383,6 +822,9 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { onClick={() => { setPending({}); setPendingCut({}); + setPendingRealCut({}); + setPendingCouples({}); + setPendingUncouple([]); }} disabled={!pendingCount} > diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index a023aeb88..b743eb585 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -347,6 +347,64 @@ export function ScheduleWorkspacePanel({ const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0; const over = capacity > 0 && used > capacity; + // One confirmation dialog for every booking action; the action fires only + // after staff confirm, and the existing toasts report the outcome. + const [confirmAction, setConfirmAction] = useState<{ + kind: "add" | "load" | "truckToTrain" | "unload" | "remove"; + bookingId: string; + ref: string; + weightTons?: number; + } | null>(null); + const confirmMeta: Record< + NonNullable["kind"], + { title: string; message: string; color: string; confirmLabel: string } + > = { + add: { + title: "Add booking to this train?", + message: + "The booking is assigned to this departure and wagons are auto-pinned. Adding past the pull-weight limit is allowed but flagged for review.", + color: "edr-green", + confirmLabel: "Add to train", + }, + load: { + title: "Load cargo onto the train?", + message: + "Stamps the booking as loaded at this yard. The server checks the train is actually standing here.", + color: "edr-green", + confirmLabel: "Load", + }, + truckToTrain: { + title: "Load as direct truck-to-train?", + message: + "Sets direct truck-to-train handover (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document) and loads the cargo.", + color: "blue", + confirmLabel: "Load direct", + }, + unload: { + title: "Unload cargo at this yard?", + message: "Stamps the booking's arrival at this yard and frees its wagons for reuse.", + color: "orange", + confirmLabel: "Unload", + }, + remove: { + title: "Remove booking from this train?", + message: + "Returns the booking to the unassigned pool, writes a removal log entry, and notifies the customer.", + color: "red", + confirmLabel: "Remove", + }, + }; + const runConfirmedAction = () => { + if (!confirmAction) return; + const { kind, bookingId, ref, weightTons } = confirmAction; + setConfirmAction(null); + if (kind === "add") forceAdd(bookingId, ref, weightTons ?? 0); + else if (kind === "load") doLoad(bookingId, ref); + else if (kind === "truckToTrain") doTruckToTrain(bookingId, ref); + else if (kind === "unload") doUnload(bookingId, ref); + else removeFromTrain(bookingId, ref); + }; + const forceAdd = (bookingId: string, ref: string, weightTons: number) => { const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity; assign @@ -649,7 +707,14 @@ export function ScheduleWorkspacePanel({ radius="md" rightSection={} loading={assign.isPending} - onClick={() => forceAdd(b.id, b.reference, b.weightTons)} + onClick={() => + setConfirmAction({ + kind: "add", + bookingId: b.id, + ref: b.reference, + weightTons: b.weightTons, + }) + } > Add @@ -794,7 +859,9 @@ export function ScheduleWorkspacePanel({ loadJourney.isPending && loadJourney.variables?.bookingId === b.id } - onClick={() => doLoad(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "load", bookingId: b.id, ref }) + } > Load @@ -812,7 +879,13 @@ export function ScheduleWorkspacePanel({ radius="md" leftSection={} loading={truckToTrainPending === b.id} - onClick={() => doTruckToTrain(b.id, ref)} + onClick={() => + setConfirmAction({ + kind: "truckToTrain", + bookingId: b.id, + ref, + }) + } > Truck to Train @@ -838,7 +911,9 @@ export function ScheduleWorkspacePanel({ unloadJourney.isPending && unloadJourney.variables?.bookingId === b.id } - onClick={() => doUnload(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "unload", bookingId: b.id, ref }) + } > Unload @@ -857,7 +932,9 @@ export function ScheduleWorkspacePanel({ unassign.isPending && unassign.variables?.bookingId === b.id } - onClick={() => removeFromTrain(b.id, ref)} + onClick={() => + setConfirmAction({ kind: "remove", bookingId: b.id, ref }) + } > Remove @@ -961,6 +1038,82 @@ export function ScheduleWorkspacePanel({ + + {/* Confirm add / load / unload / remove */} + setConfirmAction(null)} + centered + radius="lg" + size="md" + withCloseButton={false} + title={ + confirmAction ? ( + + + {confirmAction.kind === "remove" ? ( + + ) : confirmAction.kind === "unload" ? ( + + ) : confirmAction.kind === "truckToTrain" ? ( + + ) : ( + + )} + +
+ {confirmMeta[confirmAction.kind].title} + + {confirmAction.ref} + +
+
+ ) : null + } + > + {confirmAction ? ( + + {confirmMeta[confirmAction.kind].message} + {confirmAction.kind === "add" && + capacity > 0 && + used + (confirmAction.weightTons ?? 0) > capacity ? ( + + + + This add pushes the heaviest leg past the locomotive pull weight ( + {(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T). + + + ) : null} + + + + + + ) : null} +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index c9c6d9453..4509e5804 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -9,6 +9,7 @@ import { Modal, Progress, Stack, + Tabs, Text, Textarea, } from "@mantine/core"; @@ -17,8 +18,10 @@ import { isAxiosError } from "axios"; import { AlertTriangle, CalendarClock, + History, MapPin, MoreHorizontal, + PackageOpen, Power, PowerOff, Replace, @@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal"; import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal"; import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList"; +import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel"; +import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel"; import { directionColor, locomotiveStatusColor, @@ -383,7 +388,22 @@ export default function TrainBuilderDetailPage() { ]} /> - {composition.wagonYards.length > 1 ? ( + + + }> + Build + + }> + Detached wagons + + }> + History + + + + + + {composition.wagonYards.length > 1 ? ( }> @@ -575,6 +595,22 @@ export default function TrainBuilderDetailPage() { ) : null} + + + + + + + + + + + QUERY_KEYS.TRAIN_BUILDER.composition(id), ), + // Keys derive to ["train-builder", "history"|"detachedWagons", input] — the + // shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit. + history: endpoint< + { id: string; page: number; pageSize: number }, + PaginatedResponse + >("train-builder", "history", ({ id, page, pageSize }) => + trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data), + ), + + detachedWagons: endpoint< + { id: string; page: number; pageSize: number }, + PaginatedResponse + >("train-builder", "detachedWagons", ({ id, page, pageSize }) => + trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data), + ), + // Key derives to ["train-builder", "usedTrainNumbers"], so the shared // TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit. usedTrainNumbers: endpoint( diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 8ef68767c..4115635b3 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -1,3 +1,5 @@ +import type { PaginatedResponse } from "@edr/types"; + import { api as apiClient } from "../auth/http"; // --------------------------------------------------------------------------- @@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry { /** Adjust response: fresh consist + schedule-impact warnings to surface. */ export type AdjustConsistResult = ScheduleConsist & { warnings: string[] }; +/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */ +export interface TrainHistoryEntry { + id: string; + action: "ADD" | "REMOVE" | "SWITCH"; + subject: string | null; + yardLabel: string | null; + actor: string | null; + /** Set when the change came from a trip (schedule); null = train-builder edit. */ + scheduleReference: string | null; + occurredAt: string; +} + +/** Wagon last detached from this train and still loose — the re-attach shortlist. */ +export interface DetachedWagonRow { + wagonId: string; + wagonNumber: string; + wagonTypeCode: string | null; + currentYardLabel: string | null; + detachedAt: string; + detachedYardLabel: string | null; + detachedBy: string | null; +} + /** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */ export interface ScheduleWagonYardRow { id: string; @@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow { /** Drop stop this departure cuts the wagon at; null = rides to the destination. */ cutYardId: string | null; cutYardLabel: string | null; + /** true = REAL cut: the built train permanently loses the wagon at the cut yard. */ + realCut: boolean; + /** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */ + coupledYardId: string | null; + coupledYardLabel: string | null; aligned: boolean; locked: boolean; lockReason: string | null; @@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop { physical: number; /** Wagons this departure cuts (detaches and leaves) at this stop. */ cut: number; + /** Loose wagons this departure couples onto the train at this stop. */ + coupled: number; } export interface ScheduleWagonYards { @@ -340,7 +372,16 @@ export interface ScheduleWagonYards { export interface UpdateScheduleWagonYardsPayload { /** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */ - moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>; + moves?: Array<{ + wagonId: string; + yardId?: string; + cutYardId?: string | null; + realCut?: boolean; + }>; + /** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */ + couple?: Array<{ wagonId: string; yardId: string }>; + /** Wagon ids to drop from the couple plan. */ + uncouple?: string[]; } export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] }; @@ -349,6 +390,14 @@ export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), getComposition: (id: string) => apiClient.get(`${BASE}/${id}`), + getHistory: (id: string, page: number, pageSize: number) => + apiClient.get>( + `${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`, + ), + getDetachedWagons: (id: string, page: number, pageSize: number) => + apiClient.get>( + `${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`, + ), /** Import/export run numbers already claimed by existing trains. */ usedTrainNumbers: () => apiClient.get(`${BASE}/used-train-numbers`), build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index de1286ffd..d6e07399f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -568,7 +568,11 @@ export function ReadonlyBookingView({ a credit you can rebook with once the fee is settled. {booking.consolidationPartnerId ? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them." - : ""} + : ""}{" "} + + This cannot be undone from the portal — only EDR staff can revert + a cancellation request. +
{paidPreview.isLoading && } {paidPreview.data && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index aa75f0904..683851717 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -205,19 +205,6 @@ export function WagonCancellationCard({ ), }); - const withdrawMutation = useMutation({ - mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id), - onSuccess: () => { - toast.success("Cancellation withdrawn — the fee invoice was voided."); - void refetch(); - onBookingUpdated?.(); - }, - onError: (e) => - toast.error( - apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."), - ), - }); - const [rebookDate, setRebookDate] = useState(""); // Non-customs: container number / seal / VGM may change at rebook. Customs // (Path B) credits are rebooked by GL from the backoffice instead. @@ -270,9 +257,8 @@ export function WagonCancellationCard({ {fmtMoney(openRow.feeAmount, openRow.feeCurrency)} . The cancelled wagons have left the train. Pay the fee to unlock - the rebooking credit, or withdraw the request to get the wagons - back — withdrawing works only while the train still has free space - for them. + the rebooking credit. The request cannot be withdrawn from here — + if it was a mistake, contact EDR staff.
- ) : creditRow ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx index 2d0522e1b..345028ad0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx @@ -15,6 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AlertCircle, Container, Gauge, MapPin, @@ -717,8 +718,9 @@ export function WagonsTab({ )} {cancellable && hasOpenCancellation && ( - A wagon cancellation is already awaiting its fee — pay or withdraw it - in the wagon cancellation card before requesting another. + A wagon cancellation is already awaiting its fee — pay it in the + wagon cancellation card before requesting another. Withdrawing a + request is only possible through EDR staff. )} @@ -765,6 +767,13 @@ export function WagonsTab({ paid freight for them becomes a credit you can rebook on another day while your contract is valid. + }> + + This cannot be undone from the portal. Once requested, the wagons + leave the train and only EDR staff can revert the cancellation — + make sure before you confirm. + + {previewMutation.isPending && } {preview && ( => { - const { data } = await client.post( - `/api/bookings/wagon-cancellations/${cancellationId}/withdraw`, - ); - return data.data ?? data; - }, + // Withdraw was removed from the portal on purpose: a customer's cancellation + // request is final — only backoffice staff (void permission) can revert it. /** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */ rebookWagonCancellation: async ( From e2189040fa3824d8af52181f72f168d50daef3b2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 23 Aug 2026 04:49:58 +0000 Subject: [PATCH 09/10] feat: add pagination to schedule history and consolidation approvals - Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements. --- .../3680000000000-WagonAllocationSlotIndex.ts | 24 + .../modules/bookings/bookings.controller.ts | 685 +++++++++++------- .../consolidation-approval.service.spec.ts | 332 +++++++-- .../consolidation-approval.service.ts | 152 +++- .../consolidation-approvals.repository.ts | 136 +++- .../train-schedules.repository.ts | 24 + .../train-scheduling.controller.ts | 18 +- .../services/train-scheduling.service.ts | 393 ++++++---- .../trainScheduling/ScheduleHistoryPanel.tsx | 20 +- .../bookings/ConsolidationApprovalsPage.tsx | 353 ++++++--- .../TrainScheduleV2DetailPage.tsx | 39 +- .../backoffice/src/services/api.ts | 26 +- .../src/services/bookings.service.ts | 133 +++- .../src/services/trainBuilder.service.ts | 6 +- .../src/services/trainScheduling.service.ts | 18 + 15 files changed, 1746 insertions(+), 613 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts diff --git a/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts new file mode 100644 index 000000000..c5c855fb5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every slot → allocations lookup (allocator, journey load/unload, settle, + * per-leg weight guard) filters wagon_booking_allocations by + * train_set_wagon_id, which had no index — only booking_id and the pkey. + * Sequential scans grow with every allocation ever written. + */ +export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface { + name = 'WagonAllocationSlotIndex3680000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot + ON freight.wagon_booking_allocations (train_set_wagon_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index b216df4cf..03f13d186 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -17,57 +17,59 @@ import { UploadedFile, UploadedFiles, UseInterceptors, -} from '@nestjs/common'; -import { CurrentUser } from '@edr/api-common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, BookingView, MixedAudience, PortalCustomer, WagonCancellationView, -} from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +} from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AnyFilesInterceptor, FileInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, + ApiQuery, ApiTags, } from "@nestjs/swagger"; import type { Response } from "express"; -import { BookingClearanceChargeService } from './booking-clearance-charge.service'; -import { BookingPayablesService } from './booking-payables.service'; -import { ClearanceEventService } from './clearance-event.service'; +import { BookingClearanceChargeService } from "./booking-clearance-charge.service"; +import { BookingPayablesService } from "./booking-payables.service"; +import { ClearanceEventService } from "./clearance-event.service"; +import { RejectClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { BillClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { AdditionalChargeService } from "./additional-charge.service"; import { - - RejectClearanceChargeDto, -} from './dto/clearance-charge.dto'; -import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; -import { AdditionalChargeService } from './additional-charge.service'; -import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; -import { BookingContractService } from './booking-contract.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingClearanceService } from '../contracts/booking-clearance.service'; + CancelAdditionalChargeDto, + CreateAdditionalChargeDto, +} from "./dto/additional-charge.dto"; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingClearanceService } from "../contracts/booking-clearance.service"; import { AdviseContractDutyDto, RoAmendmentDto, -} from '../contracts/dto/phased-clearance.dto'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { BookingsService } from './bookings.service'; -import { ConsolidationApprovalService } from './consolidation-approval.service'; -import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; -import { CreateBookingDto } from './dto/create-booking.dto'; -import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; -import { FilterBookingDto } from './dto/filter-booking.dto'; -import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +} from "../contracts/dto/phased-clearance.dto"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { scopedDirections } from "../user-trade-access/trade-scope.util"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; +import { BookingsService } from "./bookings.service"; +import { ConsolidationApprovalService } from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; +import { CreateBookingDto } from "./dto/create-booking.dto"; +import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; +import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { AcceptIntakeDto, ApproveConsolidationDto, @@ -80,26 +82,26 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; -import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; -import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; -import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; -import { CustomerTruckService } from './customer-truck.service'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { LastMileService } from '../last-mile/last-mile.service'; -import { GenerateGrnDto } from './dto/generate-grn.dto'; -import { ContainerReceiptService } from './container-receipt.service'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; -import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; +import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; +import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; +import { CustomerTruckService } from "./customer-truck.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { LastMileService } from "../last-mile/last-mile.service"; +import { GenerateGrnDto } from "./dto/generate-grn.dto"; +import { ContainerReceiptService } from "./container-receipt.service"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { SetExportHandoverModeDto } from "./dto/set-export-handover-mode.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { BookingWagonCancellationService } from "./booking-wagon-cancellation.service"; import { FilterWagonCancellationsDto, RebookCancelledWagonsDto, RequestWagonCancellationDto, -} from './dto/wagon-cancellation.dto'; +} from "./dto/wagon-cancellation.dto"; import { type AuthUserPayload, resolveAuthUserId, @@ -136,7 +138,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { rec.vehicle?.currency ?? assignments[0]?.vehicle?.currency ?? rec.booking?.paymentCurrency ?? - 'ETB'; + "ETB"; const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ plate: a.vehicle?.plateNumber ?? null, code: a.vehicle?.code ?? null, @@ -154,7 +156,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { }); } return { - status: rec.status ?? '', + status: rec.status ?? "", exactKm: num(rec.exactKm), remainingPayment: num(rec.remainingPayment), currency, @@ -415,14 +417,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/available-days') + @Get(":id/available-days") @MixedAudience([]) @ApiOperation({ summary: - 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', + "Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)", }) async availableDays( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -438,17 +440,17 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } - @Get(':id/day-availability') + @Get(":id/day-availability") @MixedAudience([]) @ApiOperation({ summary: - 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + - 'Export: whole-booking fit + largest single-train leftover. ' + - 'Import/domestic: total room across the day for the booking\'s wagon type.', + "Advisory free-wagon count for a shipment day (planning hint, not enforced). " + + "Export: whole-booking fit + largest single-train leftover. " + + "Import/domestic: total room across the day for the booking's wagon type.", }) async dayAvailability( - @Param('id', ParseUUIDPipe) id: string, - @Query('date') date: string, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -464,13 +466,14 @@ export class BookingsController { return this.transitionService.dayAvailabilityForBooking(id, date); } - @Get(':id/mile-summary') + @Get(":id/mile-summary") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ - summary: 'First/last-mile operational summary for a booking (customer-safe)', + summary: + "First/last-mile operational summary for a booking (customer-safe)", }) async mileSummary( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // Customers may only see their own booking's mile summary. @@ -479,7 +482,10 @@ export class BookingsController { !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const [first, last] = await Promise.all([ @@ -492,82 +498,100 @@ export class BookingsController { }; } - @Post(':id/customer-truck-assignment') + @Post(":id/customer-truck-assignment") @PortalCustomer() - @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + @ApiOperation({ + summary: "Customer assigns external truck and driver for terminal pickup", + }) async assignCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CustomerTruckAssignmentDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const assigned = await this.bookingsService.assignCustomerTruck(id, dto); return this.transitionService.enrichBookingResponse(assigned); } - @Get(':id/customer-truck-assignment/freight-order') + @Get(":id/customer-truck-assignment/freight-order") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + "Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).", }) async customerTruckFreightOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, - @Query('copies') copies?: string, + @Query("copies") copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const extraCopyIndexes = (copies ?? '') - .split(',') + const extraCopyIndexes = (copies ?? "") + .split(",") .map((n) => Number(n.trim())) .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await this.bookingsService.customerTruckFreightOrderCopies( + id, + extraCopyIndexes, + ); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/carriage-acceptance-sheet') + @Get(":id/carriage-acceptance-sheet") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + "Download the carriage acceptance sheet (one per booking, lists every allocated wagon)", }) async carriageAcceptanceSheet( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + const { filename, buffer } = + await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/wagons') + @Get(":id/wagons") @ApiOperation({ summary: - 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + "Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train", }) async wagonAllocations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.bookingsService.wagonAllocations(id); } @@ -576,41 +600,52 @@ export class BookingsController { // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. - @Post(':id/wagon-cancellations/preview') - @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + @Post(":id/wagon-cancellations/preview") + @ApiOperation({ + summary: + "Preview the fee/credit of a partial wagon cancellation (no writes)", + }) async previewWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.previewCancellation(id, dto); } - @Post(':id/wagon-cancellations') + @Post(":id/wagon-cancellations") @ApiOperation({ summary: - 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + "Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", }) async requestWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.requestCancellation(id, dto, user?.id); } - @Get(':id/wagon-cancellations') - @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + @Get(":id/wagon-cancellations") + @ApiOperation({ + summary: "Wagon-cancellation history of one booking (owner or staff)", + }) async listBookingWagonCancellations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -618,19 +653,28 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); if (!staff) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); } - @Get('wagon-cancellations/my') - @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + @Get("wagon-cancellations/my") + @ApiOperation({ + summary: + "Wagon-cancellation history of the calling customer (paginated, filterable)", + }) async listMyWagonCancellations( @Query() filter: FilterWagonCancellationsDto, @CurrentUser() user: TCurrentUser, ) { - const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); - if (!companyId) throw new ForbiddenException('No customer company for this user.'); + const companyId = await this.bookingsService.resolveCustomerCompanyId( + user?.id ?? "", + ); + if (!companyId) + throw new ForbiddenException("No customer company for this user."); return this.wagonCancellationService.list({ companyId, status: filter.statuses, @@ -642,10 +686,14 @@ export class BookingsController { }); } - @Get('wagon-cancellations/history') + @Get("wagon-cancellations/history") @WagonCancellationView() - @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) - async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + @ApiOperation({ + summary: "All wagon cancellations (staff, paginated, filterable)", + }) + async listAllWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + ) { return this.wagonCancellationService.list({ status: filter.statuses, search: filter.search, @@ -656,29 +704,34 @@ export class BookingsController { }); } - @Post('wagon-cancellations/:cancellationId/withdraw') - @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.' }) + @Post("wagon-cancellations/:cancellationId/withdraw") + @ApiOperation({ + summary: + "Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.", + }) async withdrawWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @CurrentUser() user: TCurrentUser, ) { // Customer cancellations are irreversible from the portal — no owner // fallback here. Only staff holding the void permission can revert one. - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid) + ) { throw new ForbiddenException( - 'A cancellation request cannot be withdrawn from the portal — contact EDR staff.', + "A cancellation request cannot be withdrawn from the portal — contact EDR staff.", ); } return this.wagonCancellationService.withdraw(cancellationId); } - @Post('wagon-cancellations/:cancellationId/rebook') + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: - 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + "Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", }) async rebookWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @Body() dto: RebookCancelledWagonsDto, @CurrentUser() user: TCurrentUser, ) { @@ -699,180 +752,228 @@ export class BookingsController { if (hasFreightPermission(user, staffPermission)) return; const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - @Get(':id/customer-trucks') + @Get(":id/customer-trucks") @MixedAudience([ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.warehouseInventory.view, ]) - @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + @ApiOperation({ + summary: "List customer self-haul trucks (multi-truck) for a booking", + }) async listCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.listTrucks(id); } - @Post(':id/customer-trucks') + @Post(":id/customer-trucks") @PortalCustomer() - @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + @ApiOperation({ + summary: + "Add a customer self-haul truck carrying 1–2 of the booking containers", + }) async addCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addTruck(id, dto); } - @Post(':id/customer-trucks/bulk') + @Post(":id/customer-trucks/bulk") @PortalCustomer() - @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + @ApiOperation({ + summary: "Bulk add customer trucks from array payload (Excel parsed)", + }) async bulkAddCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() payload: { trucks: AddCustomerTruckDto[] }, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addBulkTrucks(id, payload.trucks); } - @Patch(':id/customer-trucks/:assignmentId') + @Patch(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + @ApiOperation({ + summary: + "Edit a not-yet-arrived customer truck (plate/driver/type + containers)", + }) async updateCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.updateTruck(id, assignmentId, dto); } - @Delete(':id/customer-trucks/:assignmentId') + @Delete(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + @ApiOperation({ + summary: "Remove a not-yet-arrived customer truck from a booking", + }) async removeCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.removeTruck(id, assignmentId); } - @Get(':id/customer-trucks/loadable-containers') + @Get(":id/customer-trucks/loadable-containers") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + @ApiOperation({ summary: "Booking containers not yet loaded onto a truck" }) async loadableContainers( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.getLoadableContainers(id); } - @Patch(':id/export-handover-mode') + @Patch(":id/export-handover-mode") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', + summary: + "Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", }) setExportHandoverMode( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SetExportHandoverModeDto, ) { - return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); + return this.bookingsService.setExportHandoverMode( + id, + dto.exportHandoverMode, + ); } - @Post(':id/customer-trucks/:assignmentId/load') + @Post(":id/customer-trucks/:assignmentId/load") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + @ApiOperation({ + summary: "Truck_dispatch: load selected containers onto a truck (staff)", + }) async loadCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: LoadCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can load a truck'); + throw new ForbiddenException("Only warehouse staff can load a truck"); } return this.customerTruckService.loadTruck(id, assignmentId, dto); } - @Post(':id/customer-trucks/:assignmentId/depart') + @Post(":id/customer-trucks/:assignmentId/depart") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + summary: + "Register an import truck leaving: containers loaded + weighed gross (staff)", }) async departCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: DepartCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { // Weighing + registering the load on exit is a warehouse/gate staff action. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can register a truck departure'); + throw new ForbiddenException( + "Only warehouse staff can register a truck departure", + ); } return this.customerTruckService.departTruck(id, assignmentId, dto); } - @Get(':id/received-pending-grn') + @Get(":id/received-pending-grn") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + @ApiOperation({ + summary: "Containers received into port but not yet on a GRN", + }) async receivedPendingGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.listReceivedPendingGrn(id); } - @Post(':id/generate-grn') + @Post(":id/generate-grn") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + "Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", }) async generateGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: GenerateGrnDto, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.generateGrn(id, dto.containerNumbers); } - @Get(':id/tracking') + @Get(":id/tracking") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Shipment tracking timeline for a booking", @@ -969,22 +1070,29 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get('clearance/et-queue') + @Get("clearance/et-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + @ApiOperation({ + summary: "GL ET queue — general customs bookings awaiting ET action", + }) getBookingEtClearanceQueue(@CurrentUser() user: unknown) { return this.bookingClearanceService.etQueue(user); } - @Get('clearance/dj-queue') + @Get("clearance/dj-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + @ApiOperation({ + summary: "GL DJ queue — general customs bookings awaiting DJ action", + }) getBookingDjClearanceQueue() { return this.bookingClearanceService.djQueue(); } - @Get(':id/clearance') - @MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments]) + @Get(":id/clearance") + @MixedAudience([ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + ]) @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", @@ -1064,7 +1172,10 @@ export class BookingsController { : undefined, cargoTypeId: cargoTypeId || undefined, cargoTypeCode: cargoTypeCode || undefined, - wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + wagons: + Number.isFinite(parsedWagons) && parsedWagons > 0 + ? parsedWagons + : undefined, }); } @@ -1161,7 +1272,10 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); return this.clearanceChargeService.listForCustomer(id); } @@ -1208,7 +1322,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document", + summary: + "GL Djibouti uploads (or replaces, until billed) the port-charges document", }) uploadPortChargeDocument( @Param("id", ParseUUIDPipe) id: string, @@ -1288,15 +1403,23 @@ export class BookingsController { @Get(":id/additional-charges") @MixedAudience(FREIGHT_PERMS.additionalCharges.view) - @ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" }) + @ApiOperation({ + summary: "Ad-hoc extra charges finance has raised against this booking", + }) async getAdditionalCharges( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.additionalCharges.view, + ); if (!isStaff) { const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const charges = await this.additionalChargeService.list(id); // A charge finance hasn't sent yet isn't the customer's to see. @@ -1308,7 +1431,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "Finance raises a new additional charge — draft, or send to the customer immediately", + summary: + "Finance raises a new additional charge — draft, or send to the customer immediately", }) createAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @@ -1316,18 +1440,29 @@ export class BookingsController { @Body() dto: CreateAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file); + return this.additionalChargeService.create( + id, + dto, + resolveAuthUserId(user), + file, + ); } @Post(":id/additional-charges/:chargeId/send") @BookingStaff(FREIGHT_PERMS.additionalCharges.send) - @ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" }) + @ApiOperation({ + summary: "Issue the draft charge's payable invoice and notify the customer", + }) sendAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user)); + return this.additionalChargeService.send( + id, + chargeId, + resolveAuthUserId(user), + ); } @Post(":id/additional-charges/:chargeId/cancel") @@ -1339,7 +1474,12 @@ export class BookingsController { @Body() dto: CancelAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason); + return this.additionalChargeService.cancel( + id, + chargeId, + resolveAuthUserId(user), + dto.reason, + ); } @Post(":id/clearance/output-documents") @@ -1377,15 +1517,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/request') + @Post(":id/clearance/transit-assignee/request") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: - 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + "GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", }) async requestBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string | undefined, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.requestTransitAssignee( @@ -1396,15 +1536,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/assign') + @Post(":id/clearance/transit-assignee/assign") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + "GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", }) async assignBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("transitAgentId", ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.assignTransitAssignee( @@ -1415,13 +1555,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/declaration') + @Post(":id/clearance/declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET uploads customs declaration on booking (GENERAL customs)", + }) async uploadBookingDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1433,26 +1575,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty') + @Post(":id/clearance/duty") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) - @UseInterceptors(FileInterceptor('attachment')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + @UseInterceptors(FileInterceptor("attachment")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET sets duty/tax on booking with notice attachment", + }) async adviseBookingDuty( - @Param('id', ParseUUIDPipe) id: string, - @Body('dutyRequired') dutyRequiredRaw: string, - @Body('amount') amountRaw: string | undefined, - @Body('currency') currency: string | undefined, - @Body('declarationSerial') declarationSerial: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("dutyRequired") dutyRequiredRaw: string, + @Body("amount") amountRaw: string | undefined, + @Body("currency") currency: string | undefined, + @Body("declarationSerial") declarationSerial: string | undefined, @UploadedFile() attachment: Express.Multer.File | undefined, @CurrentUser() user: TCurrentUser, ) { - const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dutyRequired = dutyRequiredRaw === "true" || dutyRequiredRaw === "1"; const dto: AdviseContractDutyDto = { dutyRequired, amount: - amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, - currency: currency ?? 'ETB', + amountRaw != null && amountRaw !== "" ? Number(amountRaw) : undefined, + currency: currency ?? "ETB", declarationSerial, }; const booking = await this.bookingClearanceService.adviseDuty( @@ -1464,18 +1608,18 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration') + @Post(":id/clearance/draft-declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', + "GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", }) async uploadBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, - @Body('price') priceRaw: string, - @Body('currency') currency: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("price") priceRaw: string, + @Body("currency") currency: string | undefined, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1483,20 +1627,20 @@ export class BookingsController { id, files ?? [], Number(priceRaw), - currency ?? 'ETB', + currency ?? "ETB", resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/accept') + @Post(":id/clearance/draft-declaration/accept") @PortalCustomer() @ApiOperation({ summary: - 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + "Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", }) async acceptBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.acceptDraftDeclaration( @@ -1506,30 +1650,31 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/change') + @Post(":id/clearance/draft-declaration/change") @PortalCustomer() @ApiOperation({ summary: - 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + "Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", }) async requestBookingDraftDeclarationChange( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string, @CurrentUser() user: TCurrentUser, ) { - const booking = await this.bookingClearanceService.requestDraftDeclarationChange( - id, - note, - resolveAuthUserId(user), - ); + const booking = + await this.bookingClearanceService.requestDraftDeclarationChange( + id, + note, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize-pre-clearance') + @Post(":id/clearance/finalize-pre-clearance") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + @ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" }) async finalizeBookingPreClearance( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.finalizePreClearance( @@ -1539,13 +1684,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty-slip') + @Post(":id/clearance/duty-slip") @PortalCustomer() - @UseInterceptors(FileInterceptor('file')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Customer uploads duty/tax payment slip on booking", + }) async uploadBookingDutySlip( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: AuthUserPayload, ) { @@ -1557,12 +1704,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-permit') + @Post(":id/clearance/transit-permit") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingTransitPermit( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1574,15 +1721,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/delivery-order') + @Post(":id/clearance/delivery-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingDeliveryOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, - @Body('doCollectedDate') doCollectedDate: string | undefined, + @Body("vesselArrivalDate") vesselArrivalDate: string | undefined, + @Body("doCollectedDate") doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( @@ -1594,14 +1741,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/release-order') + @Post(":id/clearance/release-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingReleaseOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselDepartureDate') vesselDepartureDate: string, + @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( @@ -1617,10 +1764,10 @@ export class BookingsController { }; } - @Post(':id/clearance/ro-amendment') + @Post(":id/clearance/ro-amendment") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { @@ -1632,10 +1779,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/export-release') + @Post(":id/clearance/export-release") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) async confirmBookingExportRelease( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.confirmExportRelease( @@ -1645,7 +1792,7 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/request-changes') + @Post(":id/staff/request-changes") @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( @@ -1857,12 +2004,31 @@ export class BookingsController { @Get("consolidation-approvals/queue") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiQuery({ + name: "status", + required: false, + enum: ConsolidationApprovalStatus, + description: "Filter to one status. Omit for pending first, then decided.", + }) + @ApiQuery({ name: "page", required: false, type: Number }) + @ApiQuery({ name: "pageSize", required: false, type: Number }) @ApiOperation({ summary: - "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", + "One page of shared-wagon pairings: pending ones first (oldest first), then the decided history with who decided each. Every row covers BOTH bookings on the wagon.", }) - consolidationApprovalQueue() { - return this.consolidationApprovalService.queue(); + consolidationApprovalQueue( + @CurrentUser() user: AuthUserPayload, + @Query("status") status?: ConsolidationApprovalStatus, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.consolidationApprovalService.queue({ + status, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + // Narrows to the yards the caller's desk is mapped to. + user, + }); } @Get(":id/consolidation-approvals") @@ -1890,6 +2056,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.note, + user, ); } @@ -1908,6 +2075,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.reason, + user, ); } @@ -1922,12 +2090,13 @@ export class BookingsController { @Body() dto: PairedDecisionDto, @CurrentUser() user: AuthUserPayload, ) { - const { booking, partner } = await this.transitionService.applyPairedDecision( - id, - dto.decision, - resolveAuthUserId(user), - { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, - ); + const { booking, partner } = + await this.transitionService.applyPairedDecision( + id, + dto.decision, + resolveAuthUserId(user), + { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, + ); // Sequential enrichment: both go back so the UI can refresh either tab. const enrichedBooking = await this.transitionService.enrichBookingResponse(booking); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts index 5f121f630..971f1d831 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -1,9 +1,9 @@ import { ConsolidationApprovalService, CONSOLIDATION_APPROVAL_PENDING, -} from './consolidation-approval.service'; -import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity'; -import { Booking } from './entities/booking.entity'; +} from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { Booking } from "./entities/booking.entity"; /** * The shared-wagon approval gate. Two customers' cargo on one wagon is a @@ -14,37 +14,53 @@ import { Booking } from './entities/booking.entity'; * decision on one side of a shared wagon is meaningless without the other), and * a decided pairing cannot be decided twice. */ -describe('ConsolidationApprovalService', () => { +describe("ConsolidationApprovalService", () => { const PENDING = { - id: 'ap-1', - bookingId: 'b-1', - partnerBookingId: 'b-2', + id: "ap-1", + bookingId: "b-1", + partnerBookingId: "b-2", status: ConsolidationApprovalStatus.Pending, - requestedBy: 'gl-user', + requestedBy: "gl-user", }; - function makeService(overrides: { - approvals?: Partial>; - bookingsRepository?: Partial>; - } = {}) { + function makeService( + overrides: { + approvals?: Partial>; + bookingsRepository?: Partial>; + bookingsService?: Partial>; + /** Yard ids the caller is scoped to; null = unrestricted. */ + yardScope?: string[] | null; + } = {}, + ) { const approvals = { findPendingForBooking: jest.fn().mockResolvedValue(null), findById: jest.fn().mockResolvedValue(PENDING), - create: jest.fn().mockResolvedValue({ id: 'ap-1' }), + create: jest.fn().mockResolvedValue({ id: "ap-1" }), decide: jest.fn().mockResolvedValue(true), - findQueue: jest.fn().mockResolvedValue([]), + findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }), + countByStatus: jest + .fn() + .mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }), findAllForBooking: jest.fn().mockResolvedValue([]), ...overrides.approvals, }; const bookingsRepository = { update: jest.fn().mockResolvedValue(undefined), createReviewNote: jest.fn().mockResolvedValue(undefined), + resolveStaffNames: jest.fn().mockResolvedValue(new Map()), ...overrides.bookingsRepository, }; const bookingsService = { - findById: jest.fn(async (id: string) => - ({ id, reference: `BK-${id}` }) as Booking, + findById: jest.fn( + async (id: string) => + ({ + id, + reference: `BK-${id}`, + originYardId: "mojo", + destinationYardId: "djibouti", + }) as Booking, ), + ...overrides.bookingsService, }; const notifier = { consolidationApprovalRequestedToStaff: jest.fn(), @@ -55,6 +71,11 @@ describe('ConsolidationApprovalService', () => { const dataSource = { transaction: jest.fn(async (cb: () => Promise) => cb()), }; + const yardScope = { + getScopedYardIds: jest + .fn() + .mockResolvedValue(overrides.yardScope ?? null), + }; const service = new ConsolidationApprovalService( approvals as never, @@ -62,27 +83,28 @@ describe('ConsolidationApprovalService', () => { bookingsService as never, notifier as never, dataSource as never, + yardScope as never, ); - return { service, approvals, bookingsRepository, notifier }; + return { service, approvals, bookingsRepository, notifier, yardScope }; } - it('holds BOTH halves at the gate when a pairing is created', async () => { + it("holds BOTH halves at the gate when a pairing is created", async () => { const { service, approvals, bookingsRepository, notifier } = makeService(); - await service.requestApproval('b-1', 'b-2', 'gl-user'); + await service.requestApproval("b-1", "b-2", "gl-user"); expect(approvals.create).toHaveBeenCalledWith( expect.objectContaining({ - bookingId: 'b-1', - partnerBookingId: 'b-2', - requestedBy: 'gl-user', + bookingId: "b-1", + partnerBookingId: "b-2", + requestedBy: "gl-user", }), ); // Neither half may sit in the operations queue while the wagon is unreviewed. - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { status: CONSOLIDATION_APPROVAL_PENDING, }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { status: CONSOLIDATION_APPROVAL_PENDING, }); expect( @@ -90,94 +112,102 @@ describe('ConsolidationApprovalService', () => { ).toHaveBeenCalledTimes(1); }); - it('does not open a second review for a pairing already pending', async () => { + it("does not open a second review for a pairing already pending", async () => { const { service, approvals } = makeService({ approvals: { findPendingForBooking: jest.fn().mockResolvedValue(PENDING), }, }); - const result = await service.requestApproval('b-1', 'b-2', 'gl-user'); + const result = await service.requestApproval("b-1", "b-2", "gl-user"); expect(result).toBe(PENDING); expect(approvals.create).not.toHaveBeenCalled(); }); - it('releases BOTH halves to Operations on approval, logging who decided', async () => { + it("releases BOTH halves to Operations on approval, logging who decided", async () => { const { service, approvals, bookingsRepository, notifier } = makeService(); - await service.approve('ap-1', 'approver-1', 'looks fine'); + await service.approve("ap-1", "approver-1", "looks fine"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'approver-1', - 'looks fine', + "approver-1", + "looks fine", + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_REQUEST_PENDING", }); // Operations only learns about the pair now — the gate is what kept it out. expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2); }); - it('sends BOTH halves back to GL on rejection, with the reason on each', async () => { + it("sends BOTH halves back to GL on rejection, with the reason on each", async () => { const { service, approvals, bookingsRepository } = makeService(); - await service.reject('ap-1', 'approver-1', 'partner cargo is wrong'); + await service.reject("ap-1", "approver-1", "partner cargo is wrong"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Rejected, - 'approver-1', - 'partner cargo is wrong', + "approver-1", + "partner cargo is wrong", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-1', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-1", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-2', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-2", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_CHANGES_REQUESTED", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_CHANGES_REQUESTED", }); }); - it('lets the requester approve their own pairing', async () => { + it("lets the requester approve their own pairing", async () => { // No maker-checker separation: the permission alone decides who may approve, // and the audit trail still records requester and approver separately. const { service, approvals } = makeService(); - await service.approve('ap-1', 'gl-user'); + await service.approve("ap-1", "gl-user"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'gl-user', + "gl-user", undefined, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); }); - it('requires a reason to reject', async () => { + it("requires a reason to reject", async () => { const { service, approvals } = makeService(); - await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow( + await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow( /reason is required/i, ); expect(approvals.decide).not.toHaveBeenCalled(); }); - it('refuses a pairing that was already decided', async () => { + it("refuses a pairing that was already decided", async () => { const { service, bookingsRepository } = makeService({ approvals: { findById: jest.fn().mockResolvedValue({ @@ -187,21 +217,203 @@ describe('ConsolidationApprovalService', () => { }, }); - await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + await expect(service.approve("ap-1", "approver-1")).rejects.toThrow( /already approved/i, ); expect(bookingsRepository.update).not.toHaveBeenCalled(); }); - it('loses cleanly when another approver decides the same pairing first', async () => { + it("loses cleanly when another approver decides the same pairing first", async () => { // decide() writes only against a still-PENDING row, so the loser of the race // affects nothing and must not move the bookings. const { service } = makeService({ approvals: { decide: jest.fn().mockResolvedValue(false) }, }); - await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + await expect(service.approve("ap-1", "approver-1")).rejects.toThrow( /already decided by someone else/i, ); }); + + it("approves a pairing that was rejected earlier, releasing both halves", async () => { + // A rejection is not final: the reviewer may change their mind, or GL may + // argue the case. Only an already-approved pairing is closed. + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Rejected, + decidedBy: "approver-1", + }), + }, + }); + + await service.approve("ap-1", "approver-2", "resolved with GL"); + + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", + }); + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_REQUEST_PENDING", + }); + }); + + it("refuses to reject a pairing that was already rejected", async () => { + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Rejected, + }), + }, + }); + + await expect( + service.reject("ap-1", "approver-1", "still wrong"), + ).rejects.toThrow(/already rejected/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it("names the requester and the decider on every queue row", async () => { + // The stored ids mean nothing to a reviewer reading the history. + const { service } = makeService({ + approvals: { + findQueuePage: jest.fn().mockResolvedValue({ + items: [ + { + ...PENDING, + status: ConsolidationApprovalStatus.Approved, + decidedBy: "approver-1", + }, + ], + total: 1, + }), + }, + bookingsRepository: { + resolveStaffNames: jest.fn().mockResolvedValue( + new Map([ + ["gl-user", "Selam GL"], + ["approver-1", "Abebe Approver"], + ]), + ), + }, + }); + + const { items, meta, counts } = await service.queue({ pageSize: 10 }); + + expect(items[0].requestedByName).toBe("Selam GL"); + expect(items[0].decidedByName).toBe("Abebe Approver"); + // Badges count the whole queue, not the page that happened to load. + expect(counts.APPROVED).toBe(4); + expect(meta).toMatchObject({ + page: 1, + pageSize: 10, + total: 1, + totalPages: 1, + }); + }); + + it("pages the queue in SQL and reports the page meta", async () => { + // The page must be cut in the query, not sliced out of a full fetch — + // otherwise ordering only holds within whatever page loaded. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 }); + const { service } = makeService({ approvals: { findQueuePage } }); + + const { meta } = await service.queue({ + status: ConsolidationApprovalStatus.Rejected, + page: 2, + pageSize: 10, + }); + + expect(findQueuePage).toHaveBeenCalledWith({ + status: ConsolidationApprovalStatus.Rejected, + page: 2, + pageSize: 10, + }); + expect(meta).toMatchObject({ + page: 2, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); + + it("narrows the queue and the badges to the caller's yards", async () => { + // A Mojo + Adama desk sees both yards' pairings, and nothing else. The + // badges must be narrowed too, or they promise rows the caller cannot open. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 }); + const countByStatus = jest + .fn() + .mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 }); + const { service } = makeService({ + approvals: { findQueuePage, countByStatus }, + yardScope: ["mojo", "adama"], + }); + + await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 }); + + expect(findQueuePage).toHaveBeenCalledWith( + expect.objectContaining({ yardIds: ["mojo", "adama"] }), + ); + expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]); + }); + + it("leaves the queue unnarrowed for an unrestricted caller", async () => { + // Super admin, `yards:view_all`, or a desk with no yard mapping at all — + // the mapping narrows access, it never grants it. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 }); + const { service } = makeService({ + approvals: { findQueuePage }, + yardScope: null, + }); + + await service.queue({ user: { id: "u-1" } }); + + expect(findQueuePage).toHaveBeenCalledWith( + expect.objectContaining({ yardIds: undefined }), + ); + }); + + it("refuses to decide a pairing outside the caller's yards", async () => { + // Hiding the row is not enough — the id is guessable from a shared link, + // and deciding moves two other yards' bookings. + const { service, bookingsRepository } = makeService({ + yardScope: ["adama"], + }); + + await expect( + service.approve("ap-1", "approver-1", undefined, { id: "u-1" }), + ).rejects.toThrow(/outside your assigned yards/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it("allows a decision when only the PARTNER half touches the caller's yard", async () => { + // The pair is one decision, so seeing one side is seeing the pairing. + const { service, bookingsRepository } = makeService({ + yardScope: ["dire-dawa"], + bookingsService: { + findById: jest.fn(async (id: string) => + id === "b-2" + ? ({ + id, + reference: "BK-b-2", + originYardId: "djibouti", + destinationYardId: "dire-dawa", + } as Booking) + : ({ + id, + reference: "BK-b-1", + originYardId: "mojo", + destinationYardId: "djibouti", + } as Booking), + ), + }, + }); + + await service.approve("ap-1", "approver-1", undefined, { id: "u-1" }); + + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts index ab4f4891d..9851b86ff 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Inject, Injectable, Logger, @@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; +import { YardScopeService } from "../rule-engine/services/yard-scope.service"; /** Where a rejected pair goes back to, so GL can fix and resubmit. */ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; @@ -25,6 +27,12 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; /** The gate's own holding status — neither half reaches Operations from here. */ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; +/** An approval row with the requester's and decider's names resolved. */ +export type ConsolidationApprovalView = ConsolidationApproval & { + requestedByName: string | null; + decidedByName: string | null; +}; + /** * The shared-wagon approval gate. * @@ -53,6 +61,7 @@ export class ConsolidationApprovalService { private readonly bookingsService: BookingsService, private readonly notifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, + private readonly yardScope: YardScopeService, ) {} /** @@ -116,8 +125,16 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, note?: string, + user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { - const approval = await this.loadPending(approvalId); + // A pairing that was rejected can still be approved later — the reviewer + // changed their mind, or GL argued the case. Only an already-approved one + // is final, since both halves have moved on to Operations by then. + const approval = await this.loadDecidable(approvalId, [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ]); + await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -125,6 +142,10 @@ export class ConsolidationApprovalService { ConsolidationApprovalStatus.Approved, decidedBy, note, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); // Lost the race to another approver deciding the same pairing. if (!claimed) { @@ -162,13 +183,17 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, reason: string, + user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { if (!reason?.trim()) { throw new BadRequestException( "A reason is required to reject a consolidation.", ); } - const approval = await this.loadPending(approvalId); + const approval = await this.loadDecidable(approvalId, [ + ConsolidationApprovalStatus.Pending, + ]); + await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -212,9 +237,88 @@ export class ConsolidationApprovalService { return { booking, partner }; } - /** Pending pairings awaiting a decision, oldest first. */ - queue(): Promise { - return this.approvals.findQueue(); + /** + * One page of the review queue, or of its history: pending pairings first, + * then the decided ones, each carrying the display name of whoever requested + * and whoever decided it — the stored ids tell a reviewer nothing. + * + * `user` narrows the whole thing to the caller's yards: a Mojo desk sees the + * pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees + * both yards' pairings. The counts behind the tabs are narrowed the same way, + * so a badge never promises rows the caller cannot open. + */ + async queue(options?: { + status?: ConsolidationApprovalStatus; + page?: number; + pageSize?: number; + /** The `/auth/me` caller. Omit only for internal, unscoped reads. */ + user?: unknown; + }): Promise<{ + items: ConsolidationApprovalView[]; + total: number; + /** Counts per status within the caller's scope — the tab badges. */ + counts: Record; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }> { + const page = Math.max(1, options?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10)); + const yardIds = await this.scopedYardIds(options?.user); + + const { items: rows, total } = await this.approvals.findQueuePage({ + status: options?.status, + yardIds, + page, + pageSize, + }); + const counts = await this.approvals.countByStatus(yardIds); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.requestedBy, r.decidedBy]), + ); + const items = rows.map((row) => ({ + ...row, + requestedByName: row.requestedBy + ? (names.get(row.requestedBy) ?? null) + : null, + decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null, + })); + + const totalPages = Math.ceil(total / pageSize); + return { + items, + total, + counts, + meta: { + page, + pageSize, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; + } + + /** + * Yard ids the caller may see, or undefined for unrestricted. + * + * Scope comes from the desk they are logged in as: `yard_positions` maps a + * position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a + * `yards:view_all` holder, and a desk with NO yard mapping all resolve to + * unrestricted — the mapping narrows access, it never grants it. + * + * Called with no user only from internal paths, which are unscoped. + */ + private async scopedYardIds(user: unknown): Promise { + if (!user) return undefined; + const scope = await this.yardScope.getScopedYardIds(user as never); + return scope ?? undefined; } /** Full decision history for one booking — who decided what, and when. */ @@ -227,12 +331,46 @@ export class ConsolidationApprovalService { return this.approvals.findPendingForBooking(bookingId); } - private async loadPending(approvalId: string): Promise { + /** + * Refuse a decision on a pairing outside the caller's yards. + * + * Hiding the row from the list is not enough on its own: the id is guessable + * from a shared link, and deciding a pairing moves two other yards' bookings. + * Same rule as the list — either half's origin or destination is enough. + */ + private async assertInScope( + approval: ConsolidationApproval, + user: unknown, + ): Promise { + const yardIds = await this.scopedYardIds(user); + if (!yardIds) return; + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + const touches = (b: Booking | null | undefined) => + !!b && + (yardIds.includes(b.originYardId) || + yardIds.includes(b.destinationYardId)); + + if (!touches(booking) && !touches(partner)) { + throw new ForbiddenException( + "This shared wagon is outside your assigned yards.", + ); + } + } + + /** Load a row and refuse it unless it is in one of the decidable states. */ + private async loadDecidable( + approvalId: string, + allowed: ConsolidationApprovalStatus[], + ): Promise { const approval = await this.approvals.findById(approvalId); if (!approval) { throw new NotFoundException(`Approval ${approvalId} not found`); } - if (approval.status !== ConsolidationApprovalStatus.Pending) { + if (!allowed.includes(approval.status)) { throw new ConflictException( `This consolidation was already ${approval.status.toLowerCase()}.`, ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts index b398e7e8c..32d36d039 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts @@ -1,11 +1,41 @@ import { Injectable } from "@nestjs/common"; -import { DataSource, In, Repository } from "typeorm"; +import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm"; import { ConsolidationApproval, ConsolidationApprovalStatus, } from "./entities/consolidation-approval.entity"; +/** + * Narrow a queue query to the caller's yards. + * + * A shared wagon is visible when EITHER half of it starts or ends at one of + * those yards — the pairing is one decision, so seeing one side is seeing the + * pairing. Yards the train merely passes through do not count: only the two + * bookings' own endpoints do. + * + * `undefined` means unrestricted and adds no predicate. An EMPTY array means + * scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it + * gets an explicit false instead of being skipped. + */ +function applyYardScope( + qb: SelectQueryBuilder, + yardIds: string[] | undefined, +): void { + if (!yardIds) return; + if (!yardIds.length) { + qb.andWhere("1 = 0"); + return; + } + qb.andWhere( + `(booking.originYardId IN (:...yardIds) + OR booking.destinationYardId IN (:...yardIds) + OR partnerBooking.originYardId IN (:...yardIds) + OR partnerBooking.destinationYardId IN (:...yardIds))`, + { yardIds }, + ); +} + /** * Persistence for the shared-wagon approval gate. Rows are never deleted — * decided rows are the audit trail of who approved which pairing and when. @@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository { return this.repository.findOne({ where: { id } }); } - /** Pending requests for the review queue, oldest first (FIFO). */ - findQueue(): Promise { - return this.repository.find({ - where: { status: ConsolidationApprovalStatus.Pending }, - relations: { - booking: { company: true }, - partnerBooking: { company: true }, - }, - order: { requestedAt: "ASC" }, - }); + /** + * One page of review-queue rows, with both bookings loaded. + * + * Pending rows are work still to do, so they come oldest first (FIFO) and + * ahead of everything else. Decided rows are history, so they come + * newest-decision-first. Ordering is done in SQL, not after the fact — a page + * sorted in memory would only be sorted within itself. + * + * `yardIds` narrows to the caller's yards (see YardScopeService); pass + * undefined for an unrestricted caller. The narrowing is a WHERE, not a + * post-filter, so the page and the total both count only visible rows. + */ + async findQueuePage(options: { + status?: ConsolidationApprovalStatus; + yardIds?: string[]; + page: number; + pageSize: number; + }): Promise<{ items: ConsolidationApproval[]; total: number }> { + const { status, yardIds, page, pageSize } = options; + const qb = this.repository + .createQueryBuilder("approval") + .leftJoinAndSelect("approval.booking", "booking") + .leftJoinAndSelect("booking.company", "company") + .leftJoinAndSelect("approval.partnerBooking", "partnerBooking") + .leftJoinAndSelect("partnerBooking.company", "partnerCompany"); + + if (status) { + qb.andWhere("approval.status = :status", { status }); + } else { + qb.addOrderBy( + `CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`, + "ASC", + ); + } + + applyYardScope(qb, yardIds); + + // Pending has no decidedAt, decided rows all do — one pair of keys orders + // both groups correctly whichever tab asked. + const [items, total] = await qb + .addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST") + .addOrderBy("approval.requestedAt", "ASC") + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + /** + * Row count per status, for the tab badges — those must show the whole + * queue, not just the page currently loaded. Narrowed by the same yard scope + * as the list, so a badge never promises rows the caller cannot open. + */ + async countByStatus( + yardIds?: string[], + ): Promise> { + const qb = this.repository + .createQueryBuilder("approval") + .select("approval.status", "status") + .addSelect("COUNT(*)", "count") + .groupBy("approval.status"); + + // The scope predicate reads both bookings, so it needs them joined even + // though the count itself selects no columns from them. + if (yardIds) { + qb.leftJoin("approval.booking", "booking").leftJoin( + "approval.partnerBooking", + "partnerBooking", + ); + } + applyYardScope(qb, yardIds); + + const rows = await qb.getRawMany<{ + status: ConsolidationApprovalStatus; + count: string; + }>(); + + const counts = { + [ConsolidationApprovalStatus.Pending]: 0, + [ConsolidationApprovalStatus.Approved]: 0, + [ConsolidationApprovalStatus.Rejected]: 0, + }; + for (const row of rows) counts[row.status] = Number(row.count); + return counts; } create(input: { @@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository { | ConsolidationApprovalStatus.Rejected, decidedBy: string | null, decisionNote?: string | null, + /** Statuses the row may be claimed FROM. Defaults to pending-only. */ + from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending], ): Promise { const result = await this.repository.update( - { id, status: ConsolidationApprovalStatus.Pending }, + { id, status: In(from) }, { status, decidedBy, @@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository { if (bookingIds.length === 0) return Promise.resolve([]); return this.repository.find({ where: [ - { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, + { + bookingId: In(bookingIds), + status: ConsolidationApprovalStatus.Pending, + }, { partnerBookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending, diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index b0cbeb886..f4f25060b 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository { return manager ? manager.getRepository(TrainSchedule) : this.repository; } + /** + * Slim consist view for read paths that only need the route stops, the + * built train, and slot→allocation existence (e.g. the schedule-yards tab): + * skips the booking/company/container branches of the full graph, which + * dominate its cost and go unused there. + */ + findByIdWithConsistLite(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relationLoadStrategy: 'query', + relations: { + route: { milestones: { yard: true } }, + trainSet: { + train: true, + locomotive: true, + locomotives: { locomotive: true }, + wagons: { allocations: true }, + }, + originStation: true, + destinationStation: true, + }, + }); + } + findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { return this.repo(manager).findOne({ where: { id }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 6a3ace1fe..a86bc7651 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../../common/resolve-auth-user-id"; +import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto"; import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service"; import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; @@ -243,14 +244,27 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/phase") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed", + }) + getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getSchedulePhase(id); + } + @Get("schedules/:id/history") @TrainSchedulingView() @ApiOperation({ summary: "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", }) - getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.getScheduleHistory(id); + getScheduleHistory( + @Param("id", ParseUUIDPipe) id: string, + @Query() query: PaginationQueryDto, + ) { + return this.trainSchedulingService.getScheduleHistory(id, query); } @Get("bookable-schedules") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 10e2b82bc..5ac6ed0bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -4379,7 +4379,10 @@ export class TrainSchedulingService { /** Log the train passing a station. Logging the destination station triggers arrival. */ async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: checkpoint logging reads stops, locomotives, the built + // train and the wagon plans — never the booking/container branches. + // (arriveSchedule, invoked on the final leg, loads its own full graph.) + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } @@ -4473,9 +4476,23 @@ export class TrainSchedulingService { const cutNow = Object.entries(cutPlan).filter(([, yardId]) => passedYardIds.includes(yardId), ); + // One fetch for the whole plan, one bulk insert per log table — the + // per-wagon UPDATEs stay (each patch differs) but the transaction no + // longer serializes a findOne + save pair per wagon. + const cutWagonById = new Map( + cutNow.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(cutNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; + const movementRows: WagonMovement[] = []; let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + const wagon = cutWagonById.get(wagonId); // Already settled earlier (or re-pinned elsewhere) — not ours to move. if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; if (realCutIds.has(wagonId) && builtTrainId) { @@ -4497,7 +4514,7 @@ export class TrainSchedulingService { AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, [wagonId, builtTrainId], ); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + adjustmentRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: builtTrainId, @@ -4519,7 +4536,7 @@ export class TrainSchedulingService { status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, }); } - await manager.getRepository(WagonMovement).save( + movementRows.push( manager.getRepository(WagonMovement).create({ wagonId, fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId, @@ -4530,6 +4547,12 @@ export class TrainSchedulingService { }), ); } + if (adjustmentRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(adjustmentRows); + } + if (movementRows.length) { + await manager.getRepository(WagonMovement).save(movementRows); + } // Keep the coupling order gapless after permanent removals. if (realCutHappened && builtTrainId) { const remaining = await manager.getRepository(Wagon).find({ @@ -4557,8 +4580,16 @@ export class TrainSchedulingService { select: { id: true, sequenceNumber: true }, }); let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + const coupleWagonById = new Map( + ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]), + ); + const coupleLogRows: ScheduleWagonAdjustmentLog[] = []; for (const [wagonId, coupleYardId] of coupleNow) { - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + const wagon = coupleWagonById.get(wagonId); if ( !wagon || wagon.trainId || @@ -4575,7 +4606,7 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + coupleLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: builtTrainId, @@ -4588,6 +4619,9 @@ export class TrainSchedulingService { }), ); } + if (coupleLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); + } } await manager .getRepository(Wagon) @@ -4798,11 +4832,23 @@ export class TrainSchedulingService { ); } + // One fetch for every pinned wagon and bulk log/ledger inserts — the + // per-wagon UPDATEs stay (patches differ per wagon). + const pinnedIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + const settleWagonById = new Map( + pinnedIds.length + ? ( + await manager.getRepository(Wagon).find({ where: { id: In(pinnedIds) } }) + ).map((w) => [w.id, w]) + : [], + ); + const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; + const arrivalMovementRows: WagonMovement[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; - const wagon = await manager - .getRepository(Wagon) - .findOne({ where: { id: slot.physicalWagonId } }); + const wagon = settleWagonById.get(slot.physicalWagonId); if (!wagon) continue; // A wagon that already alighted mid-route (unload released it, possibly // re-pinned elsewhere since) is no longer this schedule's to move. @@ -4839,7 +4885,7 @@ export class TrainSchedulingService { AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, [wagon.id, ownerTrainId], ); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: ownerTrainId, @@ -4863,7 +4909,7 @@ export class TrainSchedulingService { } // Ledger: the wagon rode this schedule to its settle yard. const slotAllocations = slot.allocations ?? []; - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: slot.boardYardId ?? schedule.originStationId, @@ -4884,10 +4930,22 @@ export class TrainSchedulingService { // so a still-loose planned couple physically rode along. const arrivalCouplePlan = schedule.plannedWagonCouples ?? {}; const arrivalTrainId = schedule.trainSet?.trainId ?? null; - for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) { - const wagon = await manager - .getRepository(Wagon) - .findOne({ where: { id: coupleWagonId } }); + const coupleEntries = Object.entries(arrivalCouplePlan); + const arrivalCoupleById = new Map( + coupleEntries.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleEntries.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + // Join sequence numbers continue after the settled consist; the max is + // read once and incremented locally — identical to re-querying after + // each join, without one consist scan per wagon. + let arrivalMaxSeq: number | null = null; + for (const [coupleWagonId, coupleYardId] of coupleEntries) { + const wagon = arrivalCoupleById.get(coupleWagonId); if (!wagon) continue; if (wagon.currentTrainScheduleId === scheduleId) { // Joined during the trip, slot-less: settle at the destination. @@ -4897,7 +4955,7 @@ export class TrainSchedulingService { status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, currentYardId: schedule.destinationStationId, }); - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: coupleYardId, @@ -4914,18 +4972,21 @@ export class TrainSchedulingService { !wagon.currentTrainScheduleId && wagon.currentYardId === coupleYardId ) { - const consist = await manager.getRepository(Wagon).find({ - where: { trainId: arrivalTrainId }, - select: { id: true, sequenceNumber: true }, - }); - const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + if (arrivalMaxSeq === null) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: arrivalTrainId }, + select: { id: true, sequenceNumber: true }, + }); + arrivalMaxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + } + arrivalMaxSeq += 1; await manager.getRepository(Wagon).update(wagon.id, { trainId: arrivalTrainId, - sequenceNumber: maxSeq + 1, + sequenceNumber: arrivalMaxSeq, status: WagonStatus.Assigned, currentYardId: schedule.destinationStationId, }); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: arrivalTrainId, @@ -4937,7 +4998,7 @@ export class TrainSchedulingService { occurredAt: now, }), ); - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: coupleYardId, @@ -4949,6 +5010,12 @@ export class TrainSchedulingService { ); } } + if (arrivalLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); + } + if (arrivalMovementRows.length) { + await manager.getRepository(WagonMovement).save(arrivalMovementRows); + } // Ensure a destination checkpoint exists so the timeline shows ARRIVED. const stations = await this.buildScheduleStations(schedule); @@ -5720,6 +5787,39 @@ export class TrainSchedulingService { return rows[0]?.train_id ?? null; } + /** + * Polling heartbeat for the detail page: one row, no joins. Clients compare + * this snapshot between polls and refetch the (expensive) full detail only + * when it changed — `updatedAt` catches any schedule-row write, the phase + * fields drive countdowns directly. + */ + async getSchedulePhase(scheduleId: string) { + const rows: Array<{ + status: string; + bookingWindowStatus: string | null; + windowPhase: string | null; + windowOpensAt: Date | null; + windowClosesAt: Date | null; + docReviewEndsAt: Date | null; + paymentPhaseEndsAt: Date | null; + updatedAt: Date; + }> = await this.dataSource.query( + `SELECT status, + booking_window_status AS "bookingWindowStatus", + window_phase AS "windowPhase", + window_opens_at AS "windowOpensAt", + window_closes_at AS "windowClosesAt", + doc_review_ends_at AS "docReviewEndsAt", + payment_phase_ends_at AS "paymentPhaseEndsAt", + updated_at AS "updatedAt" + FROM freight.train_schedules + WHERE id = $1 AND deleted_at IS NULL`, + [scheduleId], + ); + if (!rows[0]) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + return rows[0]; + } + /** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */ private async plannedWagonYardsOf( scheduleId: string | undefined, @@ -5760,17 +5860,35 @@ export class TrainSchedulingService { return rows[0]?.planned_wagon_couples ?? {}; } + /** Wagon types are near-static reference data — 60s TTL like the batch service's dims cache. */ + private wagonTypesCache: { value: WagonType[]; expiresAt: number } | null = null; + + private async loadWagonTypesCached(): Promise { + if (this.wagonTypesCache && this.wagonTypesCache.expiresAt > Date.now()) { + return this.wagonTypesCache.value; + } + const value = await this.dataSource.getRepository(WagonType).find(); + this.wagonTypesCache = { value, expiresAt: Date.now() + 60_000 }; + return value; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), - this.dataSource.getRepository(WagonType).find(), + const [wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ + this.loadWagonTypesCached(), this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), this.plannedWagonYardsOf(targetScheduleId), ]); + // Only two wagon populations can ever count below: the built train's own + // consist, or (train-less schedules) loose wagons — `if (wagon.trainId) + // continue` used to drop everything else in JS after loading the whole + // national fleet. Same result, fleet-sized query avoided. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId ? { trainId: builtTrainId } : { trainId: IsNull() }, + }); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); @@ -5945,9 +6063,16 @@ export class TrainSchedulingService { slots: TrainSetWagon[], reverseWagonOrder = false, ) { - const wagons = await manager.getRepository(Wagon).find(); - const wagonTypes = await manager.getRepository(WagonType).find(); const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); + // pickPhysicalWagonForSlot can only ever pin the built train's own wagons, + // couple-planned loose wagons, or (loose-pool schedules) wagons with no + // train — its own filters reject everything else, so don't load the fleet. + const wagons = await manager.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); + const wagonTypes = await this.loadWagonTypesCached(); const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule( scheduleId, manager, @@ -6034,11 +6159,17 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), + const [builtTrainId, pinnedToScheduleIds] = await Promise.all([ this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + // Same population argument as autoPinWagonsForSchedule: consist + loose + // wagons are the only candidates the pin filters can accept. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); const targetSchedule = targetScheduleId ? await this.trainSchedulesRepository.findById(targetScheduleId) : null; @@ -6906,7 +7037,9 @@ export class TrainSchedulingService { * (already carrying this schedule's cargo). */ async getScheduleWagonYards(scheduleId: string) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: this read needs stops, the built train, and which slots + // carry allocations — not the full booking/container branches. + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); const builtTrain = schedule.trainSet?.train; if (!builtTrain) { @@ -8227,7 +8360,7 @@ export class TrainSchedulingService { * through iam.users; rows survive wagon/train deletion (log tables carry * plain columns, no FKs). */ - async getScheduleHistory(scheduleId: string) { + async getScheduleHistory(scheduleId: string, query: { page?: number; pageSize?: number } = {}) { type HistoryRow = { id: string; kind: 'WAGON' | 'BOOKING'; @@ -8238,105 +8371,83 @@ export class TrainSchedulingService { note: string | null; occurredAt: Date; }; - const wagonRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT l.id, - l.action, - l.wagon_number AS "subject", - COALESCE(y.label, y.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - l.occurred_at AS "occurredAt" - FROM freight.schedule_wagon_adjustment_logs l - LEFT JOIN freight.yards y ON y.id = l.yard_id - LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id - WHERE l.train_schedule_id = $1 - AND l.deleted_at IS NULL - ORDER BY l.occurred_at DESC - LIMIT 200`, + const { page, pageSize, skip, take } = normalizePagination(query); + // One UNION ALL over the four event sources, paginated in SQL — the old + // shape capped each source at 200 and merge-sorted up to 800 rows in + // memory per request. Same rows, same order, same field mapping. + const historyCte = ` + SELECT l.id::text AS "id", + 'WAGON' AS "kind", + l.action AS "action", + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + NULL::text AS "note", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + UNION ALL + SELECT r.id::text, + 'BOOKING', + 'BOOKING_REMOVED', + r.booking_reference, + NULL, + COALESCE(u.username, u.email), + r.notes, + r.removed_at + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_LOADED', + b.reference, + COALESCE(oy.label, oy.code), + COALESCE(u.username, u.email), + NULL, + b.loaded_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_UNLOADED', + b.reference, + COALESCE(dy.label, dy.code), + COALESCE(u.username, u.email), + NULL, + b.arrived_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL`; + const [countRows, rows]: [Array<{ total: string }>, HistoryRow[]] = await Promise.all([ + this.dataSource.query( + `SELECT count(*) AS total FROM (${historyCte}) history`, [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'WAGON' as const, - note: null, - })); - const bookingRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT r.id, - r.booking_reference AS "subject", - r.notes AS "note", - COALESCE(u.username, u.email) AS "actor", - r.removed_at AS "occurredAt" - FROM freight.train_composition_removal_logs r - LEFT JOIN iam.users u ON u.id = r.removed_by_user_id - WHERE r.schedule_id = $1 - AND r.deleted_at IS NULL - ORDER BY r.removed_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_REMOVED', - yardLabel: null, - })); - // Per-booking journey events (load at boarding yard / unload at alighting - // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a - // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c - // rides through) each show as their own row. Append-only: these columns are - // only ever set once per booking, never cleared, so rows never disappear. - const journeyRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(oy.label, oy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.loaded_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id - LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id - WHERE b.loaded_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.loaded_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_LOADED', - note: null, - })); - const unloadRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(dy.label, dy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.arrived_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id - WHERE b.arrived_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.arrived_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_UNLOADED', - note: null, - })); - return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( - (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), - ); + ), + this.dataSource.query( + `SELECT * FROM (${historyCte}) history + ORDER BY "occurredAt" DESC + LIMIT $2 OFFSET $3`, + [scheduleId, take, skip], + ), + ]); + const total = Number(countRows[0]?.total ?? 0); + return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; } /** @@ -9752,12 +9863,24 @@ export class TrainSchedulingService { * yardId → display label for error messages that name corridor legs. One * query; unknown ids fall back to the raw id so a message never goes blank. */ + private yardLabelsCache: { value: Map; expiresAt: number } | null = null; + private async yardLabelMap(yardIds: string[]): Promise> { if (!yardIds.length) return new Map(); - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(yardIds) } }); - return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + // Yards are near-static — cache the whole label map for 60s instead of + // one IN(...) query per detail/board render. A missing id degrades exactly + // as before: the consumer falls back to the raw id. + if (!this.yardLabelsCache || this.yardLabelsCache.expiresAt <= Date.now()) { + const yards = await this.dataSource.getRepository(Yard).find(); + this.yardLabelsCache = { + value: new Map(yards.map((y) => [y.id, y.label || y.code || y.id])), + expiresAt: Date.now() + 60_000, + }; + } + const all = this.yardLabelsCache.value; + return new Map( + yardIds.filter((id) => all.has(id)).map((id) => [id, all.get(id) as string]), + ); } /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx index 729c7a5d4..f99eeaef5 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx @@ -1,6 +1,7 @@ import { Badge, Group, + Pagination, Paper, Stack, Text, @@ -8,6 +9,7 @@ import { Timeline, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { ArrowLeftRight, History, @@ -41,13 +43,18 @@ const ACTION_META: Record< * bookings removed from the composition — newest first. */ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) { + const [page, setPage] = useState(1); const historyQuery = useQuery( api.trainScheduling.scheduleHistory.queryOptions({ - input: { scheduleId }, + input: { scheduleId, page, pageSize: 20 }, enabled: Boolean(scheduleId), + // Keep the previous page on screen while the next one loads. + placeholderData: (prev) => prev, }), ); - const entries = historyQuery.data ?? []; + const entries = historyQuery.data?.items ?? []; + const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1); + const total = historyQuery.data?.meta.total ?? 0; return ( @@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin })} )} + + {totalPages > 1 ? ( + + + {total} change(s) + + + + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx index 1aebe90c3..7b3ea1780 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx @@ -10,13 +10,15 @@ import { Group, Loader, Modal, + Pagination, Paper, Stack, + Tabs, Text, Textarea, ThemeIcon, } from "@mantine/core"; -import { AlertCircle, Check, Clock, Link2, X } from "lucide-react"; +import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react"; import toast from "react-hot-toast"; import { PageContainer, PageHeader } from "@/components/page"; @@ -28,6 +30,39 @@ import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const QUEUE_KEY = ["consolidation-approvals", "queue"]; +const PAGE_SIZE = 10; + +type Status = ConsolidationApprovalRow["status"]; + +const TABS: { value: Status; label: string }[] = [ + { value: "PENDING", label: "Awaiting approval" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, +]; + +const STATUS_COLOR: Record = { + PENDING: "yellow", + APPROVED: "green", + REJECTED: "red", +}; + +const STATUS_LABEL: Record = { + PENDING: "Awaiting approval", + APPROVED: "Approved", + REJECTED: "Rejected", +}; + +const STATUS_VERB: Record = { + PENDING: "", + APPROVED: "Approved by", + REJECTED: "Rejected by", +}; + +const EMPTY_TEXT: Record = { + PENDING: "Nothing waiting for approval.", + APPROVED: "No shared wagon has been approved yet.", + REJECTED: "No shared wagon has been rejected.", +}; /** * Review queue for shared-wagon pairings. @@ -37,6 +72,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"]; * under two separate invoices, so a person signs off on the pairing first. * Approving releases BOTH bookings to Operations; rejecting sends BOTH back to * GL with the reason. + * + * Decided pairings stay on the page rather than vanishing: the decided tabs are + * the record of who signed off on which wagon and why. A rejection is not final + * either — a rejected pairing can still be approved from here once whatever + * blocked it is settled. */ export default function ConsolidationApprovalsPage() { const qc = useQueryClient(); @@ -45,16 +85,31 @@ export default function ConsolidationApprovalsPage() { kind: "approve" | "reject"; } | null>(null); const [note, setNote] = useState(""); + const [tab, setTab] = useState("PENDING"); + const [page, setPage] = useState(1); - const { - data: rows, - isLoading, - isError, - } = useQuery({ - queryKey: QUEUE_KEY, - queryFn: () => bookingsService.consolidationApprovalQueue(), + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: [...QUEUE_KEY, tab, page], + queryFn: () => + bookingsService.consolidationApprovalQueue({ + status: tab, + page, + pageSize: PAGE_SIZE, + }), + // Keeping the last page on screen while the next one loads stops the list + // from collapsing to a spinner on every page or tab click. + placeholderData: (previous) => previous, }); + const shown = data?.items ?? []; + const pageCount = Math.max(1, data?.meta.totalPages ?? 1); + const countOf = (status: Status) => data?.counts?.[status] ?? 0; + + const goToTab = (next: Status) => { + setTab(next); + setPage(1); + }; + const close = () => { setDecision(null); setNote(""); @@ -64,7 +119,10 @@ export default function ConsolidationApprovalsPage() { mutationFn: () => { if (!decision) throw new Error("No pairing selected"); return decision.kind === "approve" - ? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined) + ? bookingsService.approveConsolidation( + decision.row.id, + note.trim() || undefined, + ) : bookingsService.rejectConsolidation(decision.row.id, note.trim()); }, onSuccess: () => { @@ -73,6 +131,7 @@ export default function ConsolidationApprovalsPage() { ? "Shared wagon approved — both bookings sent to Operations" : "Shared wagon rejected — both bookings returned to GL", ); + goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED"); void qc.invalidateQueries({ queryKey: QUEUE_KEY }); close(); }, @@ -99,90 +158,190 @@ export default function ConsolidationApprovalsPage() { }> Could not load the approval queue. - ) : !rows?.length ? ( - }> - Nothing waiting for approval. - ) : ( - - {rows.map((row) => ( - - - - - - - - - Shared wagon - - - Awaiting approval - - - - - - - - - - - - Requested {formatDateTime(row.requestedAt)} - {row.scheduledDate - ? ` · ships ${formatDateTime(row.scheduledDate)}` - : ""} - - - - - - - + {countOf(value)} + + } + > + {label} + + ))} + + + {!shown.length ? ( + }> + {EMPTY_TEXT[tab]} + + ) : ( + + {shown.map((row) => ( + + + + + + + + + Shared wagon + + + {STATUS_LABEL[row.status]} + + + + + + + + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedByName + ? ` by ${row.requestedByName}` + : ""} + {row.scheduledDate + ? ` · ships ${formatDateTime(row.scheduledDate)}` + : ""} + + + + {row.status !== "PENDING" && ( + + + + + {STATUS_VERB[row.status]}{" "} + {row.decidedByName ?? "an unknown user"} + {row.decidedAt + ? ` on ${formatDateTime(row.decidedAt)}` + : ""} + + {row.decisionNote && ( + + “{row.decisionNote}” + + )} + + + )} + + + {row.status !== "APPROVED" && ( + + + {row.status === "PENDING" && ( + + )} + + )} + + + ))} + + {pageCount > 1 && ( + + + Showing {(page - 1) * PAGE_SIZE + 1}– + {Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "} + {data?.total ?? 0} + + - - - ))} - + )} + + )} + )} - {decision?.kind === "approve" - ? "Approve this shared wagon?" - : "Reject this shared wagon?"} + {decision?.kind !== "approve" + ? "Reject this shared wagon?" + : decision.row.status === "REJECTED" + ? "Approve this rejected shared wagon?" + : "Approve this shared wagon?"} } > - {decision?.kind === "approve" - ? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately." - : "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."} + {decision?.kind !== "approve" + ? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations." + : decision.row.status === "REJECTED" + ? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations." + : "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}