mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 04:45:03 +00:00
Merge pull request #1456 from Tria-plc/Wrehousechanges
feat(loading): notify loaded and left-behind containers, surface the CAS
This commit is contained in:
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
|
|||||||
arrivalAt: string | null;
|
arrivalAt: string | null;
|
||||||
containerNumbers: string | null;
|
containerNumbers: string | null;
|
||||||
sealNumbers: 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. */
|
/** 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
|
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||||
* the wagons are allocated before marshalling (import), so it is only available
|
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||||
* once the booking has wagon allocations.
|
* 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 }> {
|
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
@@ -285,6 +291,7 @@ export class BookingsService {
|
|||||||
s.scheduled_departure_date AS "departureAt",
|
s.scheduled_departure_date AS "departureAt",
|
||||||
so.label AS "marshalledAt",
|
so.label AS "marshalledAt",
|
||||||
sd.label AS "arrivalAt",
|
sd.label AS "arrivalAt",
|
||||||
|
a.status AS "status",
|
||||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||||
FROM freight.wagon_booking_allocations a
|
FROM freight.wagon_booking_allocations a
|
||||||
@@ -299,7 +306,7 @@ export class BookingsService {
|
|||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
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
|
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
|
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||||
ORDER BY tsw.sequence_no`,
|
ORDER BY tsw.sequence_no`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
@@ -383,6 +390,8 @@ export class BookingsService {
|
|||||||
arrivalAt: null,
|
arrivalAt: null,
|
||||||
containerNumbers: row.containerNumbers,
|
containerNumbers: row.containerNumbers,
|
||||||
sealNumbers: row.sealNumbers ?? null,
|
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 header = wagons[0];
|
||||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
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) => ({
|
(acc, w) => ({
|
||||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||||
@@ -507,7 +526,7 @@ export class BookingsService {
|
|||||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||||
);
|
);
|
||||||
// A wagon carrying no weight and no container is running empty under this booking.
|
// 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),
|
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -525,6 +544,9 @@ export class BookingsService {
|
|||||||
<td>${esc(departureStation)}</td>
|
<td>${esc(departureStation)}</td>
|
||||||
<td>${esc(w.containerNumbers)}</td>
|
<td>${esc(w.containerNumbers)}</td>
|
||||||
<td>${esc(w.sealNumbers)}</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>
|
<td class="num">${money(prices[i])}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
)
|
)
|
||||||
@@ -535,11 +557,11 @@ export class BookingsService {
|
|||||||
// figure from the printed sheet.
|
// figure from the printed sheet.
|
||||||
const totalsRow = `<tr class="totals">
|
const totalsRow = `<tr class="totals">
|
||||||
<td>TOT</td>
|
<td>TOT</td>
|
||||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||||
<td>${
|
<td>${
|
||||||
pendingWagons
|
pendingWagons
|
||||||
? 'pending marshalling'
|
? 'pending marshalling'
|
||||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||||
}</td>
|
}</td>
|
||||||
<td class="num">${num(totals.tare, 2)}</td>
|
<td class="num">${num(totals.tare, 2)}</td>
|
||||||
<td class="num">${num(totals.length)}</td>
|
<td class="num">${num(totals.length)}</td>
|
||||||
@@ -549,6 +571,7 @@ export class BookingsService {
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||||
<td class="num">${money(totalAmount)}</td>
|
<td class="num">${money(totalAmount)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
@@ -575,6 +598,8 @@ export class BookingsService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
.loaded { color: #0f766e; font-weight: 700; }
|
||||||
|
.pending { color: #b45309; font-weight: 700; }
|
||||||
tr.totals td { background: #f8fafc; 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; }
|
.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; }
|
.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>Departure Station</th>
|
||||||
<th>Container No.</th>
|
<th>Container No.</th>
|
||||||
<th>Seal No.</th>
|
<th>Seal No.</th>
|
||||||
|
<th>Status</th>
|
||||||
<th class="num">Price (${esc(currency)})</th>
|
<th class="num">Price (${esc(currency)})</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
|
|||||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
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 { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.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.
|
* 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
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||||
// bookings (intercity) and already-completed codes.
|
// bookings (intercity) and already-completed codes.
|
||||||
|
|||||||
@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
};
|
};
|
||||||
booking: {
|
booking: {
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: dto.direction,
|
direction: dto.direction,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId,
|
||||||
},
|
},
|
||||||
booking,
|
booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: bookingDirection,
|
direction: bookingDirection,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId: dto.bookingId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return saved.id;
|
return saved.id;
|
||||||
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
|
||||||
|
bookingId?: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const phone = params.phone?.trim();
|
|
||||||
if (!phone) return;
|
|
||||||
|
|
||||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||||
const bookingReference = params.bookingReference?.trim();
|
const bookingReference = params.bookingReference?.trim();
|
||||||
const message =
|
const message =
|
||||||
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
|
|||||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||||
`Thank you.`;
|
`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 {
|
try {
|
||||||
await this.notifications.directSend('sms', phone, message);
|
await this.notifications.directSend('sms', phone, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1909,6 +1909,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:additional_charges:get_notification",
|
"edr_freight_app:additional_charges:get_notification",
|
||||||
"Receive additional charge notifications",
|
"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[] = [
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
@@ -2375,6 +2380,12 @@ export const FREIGHT_PERMS = {
|
|||||||
release: "edr_freight_app:warehouse_inventory:release",
|
release: "edr_freight_app:warehouse_inventory:release",
|
||||||
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||||
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
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: {
|
interchangeDocuments: {
|
||||||
view: "edr_freight_app:interchange_documents:view",
|
view: "edr_freight_app:interchange_documents:view",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||||
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||||
import type { KpiItem } from "@/components/page";
|
import type { KpiItem } from "@/components/page";
|
||||||
import { EntityLink } from "@/components/detail";
|
import { EntityLink } from "@/components/detail";
|
||||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||||
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
// Blob response: the JSON reason is inside the Blob, so
|
||||||
error instanceof Error
|
// the sync path would show only "status code 400".
|
||||||
? error.message
|
toast.error(await extractDownloadErrorMessage(error));
|
||||||
: "Carriage acceptance sheet is not available yet",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
|
|||||||
|
|
||||||
import toast from "react-hot-toast";
|
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 { bookingsService } from "@/services/bookings.service";
|
||||||
import type { EmptyContainerReturn } from "@/services/bookings.service";
|
import type { EmptyContainerReturn } from "@/services/bookings.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
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.
|
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
||||||
const [bundleBusy, setBundleBusy] = useState(false);
|
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 () => {
|
const downloadWarehouseDocuments = async () => {
|
||||||
setBundleBusy(true);
|
setBundleBusy(true);
|
||||||
const ref = booking.reference ?? booking.id;
|
const ref = booking.reference ?? booking.id;
|
||||||
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</SectionCard>
|
</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) ──────────────────────── */}
|
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Warehouse documents</CardTitle>
|
<CardTitle>Warehouse documents</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user