feat(loading): notify loaded and left-behind containers, surface the CAS

A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.

The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.

That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.

The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
This commit is contained in:
Hagernesh
2026-08-29 08:47:00 +00:00
parent abf856547a
commit fd6f0d03b6
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>