mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user