diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 71107650f..5cdb0d6a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow { arrivalAt: string | null; containerNumbers: string | null; sealNumbers: string | null; + /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ + status: string | null; } /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ @@ -263,9 +265,13 @@ export class BookingsService { /** * Carriage acceptance sheet — one per booking, listing every wagon the booking - * occupies. Handed to the customer when EDR accepts the cargo (export) and when - * the wagons are allocated before marshalling (import), so it is only available - * once the booking has wagon allocations. + * occupies. A booking is routinely loaded in parts (some containers go, the + * rest wait for the next train), so each row carries a Status of Loaded or + * Not loaded and the totals count only the loaded ones: the customer sees the + * whole plan on one page without the sheet overstating what EDR has taken. + * + * Handed to the customer when EDR accepts the cargo (export) and when the + * wagons are allocated before marshalling (import). */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); @@ -285,6 +291,7 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" FROM freight.wagon_booking_allocations a @@ -299,7 +306,7 @@ export class BookingsService { LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL - GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label ORDER BY tsw.sequence_no`, [bookingId], @@ -383,6 +390,8 @@ export class BookingsService { arrivalAt: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, + // A received line has no allocation; it is cargo EDR already holds. + status: null, })); } @@ -497,7 +506,17 @@ export class BookingsService { const header = wagons[0]; const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); - const totals = wagons.reduce( + // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the + // totals count only those: the sheet shows the whole plan, but must never + // total up cargo still sitting in the yard. A received-line sheet + // (pendingWagons) has no allocation status, and every line on it is cargo + // already accepted, so it counts in full. + const isLoaded = (w: CarriageAcceptanceWagonRow) => + pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED'; + const loadedWagons = wagons.filter(isLoaded); + const notLoadedCount = wagons.length - loadedWagons.length; + + const totals = loadedWagons.reduce( (acc, w) => ({ tare: acc.tare + (Number(w.tareWeightTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), @@ -507,7 +526,7 @@ export class BookingsService { { tare: 0, capacity: 0, load: 0, length: 0 }, ); // A wagon carrying no weight and no container is running empty under this booking. - const fullWagons = wagons.filter( + const fullWagons = loadedWagons.filter( (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), ).length; @@ -525,6 +544,9 @@ export class BookingsService { ${esc(departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} + ${ + pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded' + } ${money(prices[i])} `, ) @@ -535,11 +557,11 @@ export class BookingsService { // figure from the printed sheet. const totalsRow = ` TOT - ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} ${ pendingWagons ? 'pending marshalling' - : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}` } ${num(totals.tare, 2)} ${num(totals.length)} @@ -549,6 +571,7 @@ export class BookingsService { + ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''} ${money(totalAmount)} `; @@ -575,6 +598,8 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + .loaded { color: #0f766e; font-weight: 700; } + .pending { color: #b45309; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } @@ -618,6 +643,7 @@ export class BookingsService { Departure Station Container No. Seal No. + Status Price (${esc(currency)}) diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 467b172ba..1dd8ad9ca 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady( logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); } } + +/** One container line on the load manifest notice. */ +interface LoadManifestLists { + reference: string; + companyId: string | null; + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + loaded: string[]; + leftBehind: string[]; +} + +/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */ +function summarizeNumbers(numbers: string[], max = 5): string { + if (numbers.length === 0) return 'none'; + const shown = numbers.slice(0, max).join(', '); + const rest = numbers.length - max; + return rest > 0 ? `${shown} +${rest} more` : shown; +} + +/** + * Read what actually went on the train and what did not. Left behind = every + * container the customer declared minus the ones sitting on a LOADED/DEPARTED + * wagon, so a booking loaded in parts reports honestly on both halves. + */ +export async function loadManifestLists( + dataSource: DataSource, + bookingId: string, + trainScheduleId: string, +): Promise { + const [booking]: Array<{ reference: string; companyId: string | null }> = + await dataSource.query( + `SELECT reference, company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) return null; + + const [train]: Array<{ + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + }> = await dataSource.query( + `SELECT s.train_number AS "trainNumber", + so.label AS "originStation", + sd.label AS "destinationStation", + s.scheduled_departure_date AS "departureAt" + FROM freight.train_schedules s + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + WHERE s.id = $1 AND s.deleted_at IS NULL`, + [trainScheduleId], + ); + + const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT ci.container_number AS "containerNumber" + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations a + ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL + WHERE a.booking_id = $1 + AND ci.deleted_at IS NULL + AND a.status IN ('LOADED', 'DEPARTED') + ORDER BY 1`, + [bookingId], + ); + const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT u.container_number AS "containerNumber" + FROM freight.booking_container_units u + JOIN freight.booking_container l + ON l.id = u.booking_container_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND u.deleted_at IS NULL + ORDER BY 1`, + [bookingId], + ); + + const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[]; + const loadedSet = new Set(loaded); + const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter( + (n) => !loadedSet.has(n), + ); + + return { + reference: booking.reference, + companyId: booking.companyId, + trainNumber: train?.trainNumber ?? null, + originStation: train?.originStation ?? null, + destinationStation: train?.destinationStation ?? null, + departureAt: train?.departureAt ?? null, + loaded, + leftBehind, + }; +} + +/** + * Tell the customer what boarded the train and what did not, over in-app + SMS + * + email, and raise a warehouse-desk notice for anything left behind so + * somebody owns finding it space. A booking is routinely loaded in parts, and + * before this the customer learnt about it only by reading the sheet. + * + * Best-effort throughout: loading must never roll back because a provider is + * down. + */ +export async function notifyLoadManifest( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + trainScheduleId: string, + warehouseNotificationPermission: string, + logger: Logger, +): Promise { + try { + const m = await loadManifestLists(dataSource, bookingId, trainScheduleId); + if (!m) return; + + const route = + m.originStation && m.destinationStation + ? ` ${m.originStation} → ${m.destinationStation}` + : ''; + const departs = m.departureAt + ? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}` + : ''; + const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train'; + + const headline = + `Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` + + `${route}${departs}.`; + const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : ''; + const leftLine = + m.leftBehind.length > 0 + ? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` + + ' These stay with EDR — once a warehouse is assigned you will receive the GRN.' + : ''; + const body = headline + loadedLine + leftLine; + + if (m.companyId) { + await inbox.notify({ + recipients: { companyId: m.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded', + // The in-app copy carries every number; SMS and email get the summary. + body: + headline + + (m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') + + (m.leftBehind.length > 0 + ? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.` + : ''), + link: `/bookings/${bookingId}`, + data: { + bookingId, + reference: m.reference, + trainNumber: m.trainNumber, + loaded: m.loaded, + leftBehind: m.leftBehind, + }, + }); + await sendCompanyChannels(dataSource, notifications, m.companyId, body); + } + + // Nothing left behind is nothing for the warehouse desk to place. + if (m.leftBehind.length > 0) { + await inbox.notify({ + recipients: { permissionKeys: [warehouseNotificationPermission] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`, + body: + `${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` + + `${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`, + link: `/dashboard/booking-requests/${bookingId}`, + data: { bookingId, reference: m.reference, leftBehind: m.leftBehind }, + }); + } + } catch (err) { + logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 07de05853..46cbdb2f6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; -import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; +import { + notifyCarriageAcceptanceReady, + notifyLoadManifest, +} from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -267,6 +271,20 @@ export class BookingJourneyService { }); }); + // What actually boarded, and what did not. A booking is routinely loaded in + // parts; the customer is told both halves, and the warehouse desk is told + // about the leftovers so somebody owns placing them. After the transaction: + // the lists are read back from the allocation statuses it just wrote. + void notifyLoadManifest( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + scheduleId, + FREIGHT_PERMS.warehouseInventory.getNotification, + this.logger, + ); + // Customer tracking: cargo is on the train — loading milestones plus the // direction's "departed" handoff. Doc-trigger path no-ops non-customs // bookings (intercity) and already-completed codes. diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index aa032c17d..43e7d7ae8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1509,6 +1509,7 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + bookingId?: string | null; }; booking: { companyId?: string | null; @@ -1730,6 +1731,7 @@ export class WarehouseInventoryService { grnNumber, direction: dto.direction, warehouseId: dto.warehouseId, + bookingId, }, booking, bookingId, @@ -3107,6 +3109,7 @@ export class WarehouseInventoryService { grnNumber, direction: bookingDirection, warehouseId: dto.warehouseId, + bookingId: dto.bookingId ?? null, }); return saved.id; @@ -6614,10 +6617,9 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + /** Resolves the company, which unlocks in-app + email alongside the SMS. */ + bookingId?: string | null; }): Promise { - const phone = params.phone?.trim(); - if (!phone) return; - const ownerName = params.ownerName?.trim() || 'Customer'; const bookingReference = params.bookingReference?.trim(); const message = @@ -6627,6 +6629,47 @@ export class WarehouseInventoryService { (params.direction ? `Direction: ${params.direction}. ` : '') + `Thank you.`; + // A booking gives us the company, and with it the customer's inbox and + // email — not just whatever phone number the gate clerk typed. Without one + // (manual or backlog receive) the typed phone is all there is, so the + // original SMS-only path stands. + let companyId: string | null = null; + if (params.bookingId) { + try { + const [row]: Array<{ companyId: string | null }> = await this.dataSource.query( + `SELECT company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [params.bookingId], + ); + companyId = row?.companyId ?? null; + } catch (error) { + this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`); + } + } + + if (companyId) { + try { + await this.inbox.notify({ + recipients: { companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Cargo received — GRN issued', + body: message, + link: params.bookingId ? `/bookings/${params.bookingId}` : undefined, + data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null }, + }); + // Sends SMS *and* email to the company's own contacts, so the typed + // phone below is skipped to avoid texting the customer twice. + await sendCompanyChannels(this.dataSource, this.notifications, companyId, message); + return; + } catch (error) { + this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`); + } + } + + const phone = params.phone?.trim(); + if (!phone) return; try { await this.notifications.directSend('sms', phone, message); } catch (error) { 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 90a7310c8..4dad70ee1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1909,6 +1909,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:additional_charges:get_notification", "Receive additional charge notifications", ), + perm( + "f3a00001-0001-4000-8000-00000000000a", + "edr_freight_app:warehouse_inventory:get_notification", + "Receive warehouse desk notifications (containers left behind at loading)", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -2375,6 +2380,12 @@ export const FREIGHT_PERMS = { release: "edr_freight_app:warehouse_inventory:release", deliver: "edr_freight_app:warehouse_inventory:deliver", inspect: "edr_freight_app:warehouse_inventory:inspect", + /** + * Notification selector, not a route guard — who gets pinged when cargo is + * left behind at loading and needs warehouse space. Assign it to whichever + * desk owns that; it grants access to nothing. + */ + getNotification: "edr_freight_app:warehouse_inventory:get_notification", }, interchangeDocuments: { view: "edr_freight_app:interchange_documents:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 88ac1218d..c372f5829 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -40,6 +40,7 @@ import { } from "@mantine/core"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; import type { KpiItem } from "@/components/page"; import { EntityLink } from "@/components/detail"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; @@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() { a.click(); URL.revokeObjectURL(url); } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Carriage acceptance sheet is not available yet", - ); + // Blob response: the JSON reason is inside the Blob, so + // the sync path would show only "status code 400". + toast.error(await extractDownloadErrorMessage(error)); } }} > diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx index 46c19ea7a..25f601628 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx @@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc import toast from "react-hot-toast"; +/** + * A `responseType: "blob"` request delivers its JSON error body as a Blob, so + * reading `error.message` gives "Request failed with status code 400" instead + * of the reason. Read the blob back before falling back. + */ +async function downloadErrorMessage(error: unknown, fallback: string): Promise { + const data = (error as { response?: { data?: unknown } })?.response?.data; + if (data instanceof Blob) { + try { + const parsed = JSON.parse(await data.text()) as { message?: unknown }; + if (parsed?.message) return String(parsed.message); + } catch { + /* not JSON — fall through */ + } + } + return error instanceof Error ? error.message : fallback; +} + + import { bookingsService } from "@/services/bookings.service"; import type { EmptyContainerReturn } from "@/services/bookings.service"; import { saveBlob } from "@/utils/download"; @@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { // One-click warehouse-document bundle: GRN + gate clearance + handover. const [bundleBusy, setBundleBusy] = useState(false); + + // The carriage acceptance sheet is its own document, not warehouse paperwork: + // direct truck-to-train cargo never sees a warehouse, and this sheet IS its + // handover record. Hiding it inside the warehouse bundle made it unfindable. + const [casBusy, setCasBusy] = useState(false); + const downloadCarriageAcceptance = async () => { + setCasBusy(true); + const ref = booking.reference ?? booking.id; + try { + const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id); + saveBlob(blob, `carriage-acceptance-${ref}.pdf`); + } catch (error) { + toast.error( + await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."), + ); + } finally { + setCasBusy(false); + } + }; const downloadWarehouseDocuments = async () => { setBundleBusy(true); const ref = booking.reference ?? booking.id; @@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── Carriage acceptance sheet (its own document) ────────────────── */} + + Carriage acceptance sheet + + The record of the cargo EDR has accepted for carriage, listing each wagon and the + containers on it, and marking which have been loaded. + + + + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents