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

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