Merge pull request #1456 from Tria-plc/Wrehousechanges

feat(loading): notify loaded and left-behind containers, surface the CAS
This commit is contained in:
Hagernesh Tadesse
2026-08-29 11:50:20 +03:00
committed by GitHub
7 changed files with 351 additions and 17 deletions

View File

@@ -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 {
<td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
@@ -535,11 +557,11 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
@@ -549,6 +571,7 @@ export class BookingsService {
<td></td>
<td></td>
<td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
@@ -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 {
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>

View File

@@ -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<LoadManifestLists | null> {
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<void> {
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}`);
}
}

View File

@@ -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.

View File

@@ -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<void> {
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) {

View File

@@ -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",

View File

@@ -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));
}
}}
>

View File

@@ -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<string> {
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 }) {
</SectionCard>
)}
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
<SectionCard>
<CardTitle>Carriage acceptance sheet</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
The record of the cargo EDR has accepted for carriage, listing each wagon and the
containers on it, and marking which have been loaded.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
variant="light"
loading={casBusy}
onClick={downloadCarriageAcceptance}
>
Download sheet
</Button>
</SectionCard>
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>