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

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