mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Customer portal card displays warehouse location data (warehouse/yard/zone + arrival time) once cargo arrives at warehouse. Shows loading state during fetch, placeholder text if not yet received. Type-check passes.
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
||||
* warehouses module delivered the goods and asks the booking to complete.
|
||||
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
||||
*/
|
||||
@OnEvent('import.handover.completed')
|
||||
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
||||
try {
|
||||
await this.complete(payload.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.log(
|
||||
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
|
||||
@@ -1228,9 +1228,9 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||
* window. One query for the whole page.
|
||||
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||
* generated→signed window. One query for the whole page.
|
||||
*/
|
||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||
const ids = bookings.map((b) => b.id);
|
||||
@@ -1239,8 +1239,7 @@ export class BookingsService {
|
||||
`SELECT DISTINCT booking_id AS "bookingId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE booking_id = ANY($1::uuid[])
|
||||
AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'`,
|
||||
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||
[ids],
|
||||
);
|
||||
const pending = new Set(rows.map((r) => r.bookingId));
|
||||
@@ -1583,14 +1582,12 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
// A generated-but-unsigned handover means the customer must approve delivery
|
||||
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('handovers/:handoverId/sign')
|
||||
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||
signHandover(
|
||||
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.signHandover(
|
||||
handoverId,
|
||||
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||
async bookingHandoverDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Res() res: Response,
|
||||
@Query('handoverId') handoverId?: string,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
|
||||
bookingId,
|
||||
handoverId || undefined,
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Between,
|
||||
@@ -25,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import {
|
||||
@@ -409,6 +411,7 @@ export class WarehouseInventoryService {
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -3148,7 +3151,7 @@ export class WarehouseInventoryService {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
const [edrDeparted] = (await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
@@ -3162,7 +3165,8 @@ export class WarehouseInventoryService {
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
AND va.deleted_at IS NULL
|
||||
RETURNING va.id`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
@@ -3170,7 +3174,31 @@ export class WarehouseInventoryService {
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
)) as [Array<{ id: string }>, unknown];
|
||||
// EDR last-mile: the handover is generated the moment the truck exits
|
||||
// (with its exit paper) — one per truck — and the customer is asked to
|
||||
// sign it from the portal. Booking-level fallback when the plate matched
|
||||
// no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
|
||||
for (const row of edrDeparted) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
if (!edrDeparted.length) {
|
||||
const [lm]: Array<{ id: string }> = await manager.query(
|
||||
`SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (lm) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim() },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Customer self-haul: the same exit record on the customer's own truck.
|
||||
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||
// summed to zero and the booking could take unlimited trucks. Matched by
|
||||
@@ -3220,11 +3248,43 @@ export class WarehouseInventoryService {
|
||||
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||
if (isTruckLeaving && item.bookingId) {
|
||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||
} else if (item.bookingId) {
|
||||
// Gate-in: same single-path hook for the arrival side (self-haul + EDR).
|
||||
void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
|
||||
private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck arrived at the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
@@ -3906,8 +3966,205 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Customer signs ONE handover from the portal (EDR last-mile: one per truck).
|
||||
* When the last one is signed — and every EDR truck has left the warehouse —
|
||||
* the delivery completes automatically: inventory + cargo delivered, last-mile
|
||||
* leg DELIVERED (trucks freed), booking completed ("shipment delivered").
|
||||
*/
|
||||
async signHandover(
|
||||
handoverId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{
|
||||
handoverId: string;
|
||||
bookingId: string;
|
||||
signedAt: string | null;
|
||||
signerDisplayName: string;
|
||||
allSigned: boolean;
|
||||
}> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to sign the handover');
|
||||
}
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||
}
|
||||
|
||||
const [h]: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
mileType: string;
|
||||
edrAssignmentId: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
|
||||
mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[handoverId],
|
||||
);
|
||||
if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||
|
||||
// Same gate as approve-delivery: storage/demurrage must be settled first.
|
||||
const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||||
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[h.bookingId],
|
||||
);
|
||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const signed = await this.handover.sign(handoverId, userId, name);
|
||||
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||
|
||||
if (inv) {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: inv.id,
|
||||
warehouseId: inv.warehouseId,
|
||||
description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
|
||||
performedBy: name,
|
||||
});
|
||||
}
|
||||
|
||||
// EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
|
||||
// goods THIS truck carried, so only its containers become DELIVERED now.
|
||||
// (Self-haul keeps the single booking-level handover + manual Deliver.)
|
||||
if (h.mileType === 'EDR_LAST_MILE') {
|
||||
try {
|
||||
await this.deliverEdrTruckContainers(h, name);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (allSigned) {
|
||||
void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
|
||||
this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
handoverId,
|
||||
bookingId: h.bookingId,
|
||||
signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
|
||||
signerDisplayName: name,
|
||||
allSigned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile auto-completion: once every handover is signed and every EDR
|
||||
* truck has departed, deliver the remaining inventory, mark the last-mile leg
|
||||
* DELIVERED and complete the booking. Self-haul bookings keep their manual
|
||||
* Deliver flow (no last_mile record ⇒ no-op).
|
||||
*/
|
||||
private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
|
||||
const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
|
||||
`SELECT id, status FROM freight.last_mile
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!lm) return;
|
||||
|
||||
const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (Number(pending?.notDeparted ?? 0) > 0) return;
|
||||
if (!(await this.handover.isFullySigned(bookingId))) return;
|
||||
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: 'Auto-delivered on customer handover signature',
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lm.status !== 'DELIVERED') {
|
||||
try {
|
||||
await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Booking → COMPLETED ("shipment delivered" notification) — owned by the
|
||||
// bookings module; evented to avoid a warehouses→bookings service dependency.
|
||||
this.events.emit('import.handover.completed', { bookingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
|
||||
* so only the container items that truck carried become DELIVERED. Bulk cargo
|
||||
* (no container rows) is delivered by completeEdrDeliveryIfReady once every
|
||||
* truck is signed off.
|
||||
*/
|
||||
private async deliverEdrTruckContainers(
|
||||
h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
|
||||
signerName: string,
|
||||
): Promise<void> {
|
||||
if (!h.edrAssignmentId && !h.truckPlate) return;
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT inv.id
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
JOIN freight.containers c
|
||||
ON c.container_number = COALESCE(vc.container_number, va.container_number)
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND inv.status = 'READY_FOR_PICKUP'
|
||||
AND (va.id = $2::uuid
|
||||
OR ($2::uuid IS NULL
|
||||
AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
|
||||
[h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handover PDF resolved by booking (for the portal, which only has bookingId).
|
||||
* With `handoverId` the document is rendered for that specific handover — the
|
||||
* per-truck EDR last-mile variant (truck plate + that truck's signature state).
|
||||
*/
|
||||
async handoverDocumentForBooking(
|
||||
bookingId: string,
|
||||
handoverId?: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
@@ -3918,7 +4175,29 @@ export class WarehouseInventoryService {
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id);
|
||||
if (!handoverId) return this.handoverDocument(inv.id);
|
||||
|
||||
const [h]: Array<{
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: string | null;
|
||||
signerName: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT reference, truck_plate AS "truckPlate",
|
||||
signed_at AS "signedAt", signer_name AS "signerName"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
|
||||
[handoverId, bookingId],
|
||||
);
|
||||
if (!h) {
|
||||
throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id, {
|
||||
reference: h.reference,
|
||||
truckPlate: h.truckPlate,
|
||||
signedAt: h.signedAt ? new Date(h.signedAt) : null,
|
||||
signerName: h.signerName,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
@@ -3946,7 +4225,15 @@ export class WarehouseInventoryService {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
async handoverDocument(
|
||||
id: string,
|
||||
perTruck?: {
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: Date | null;
|
||||
signerName: string | null;
|
||||
},
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
@@ -4033,12 +4320,14 @@ export class WarehouseInventoryService {
|
||||
|
||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||
const reference =
|
||||
perTruck?.reference ||
|
||||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
||||
if (!generatedAtValue) {
|
||||
// Per-truck renders must not stamp their reference into the shared item notes.
|
||||
if (!generatedAtValue && !perTruck) {
|
||||
await this.inventoryRepository.update(id, {
|
||||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||
});
|
||||
@@ -4072,7 +4361,16 @@ export class WarehouseInventoryService {
|
||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||
trainSchedule: row.trainSchedule ?? null,
|
||||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||
truckPlate: perTruck?.truckPlate ?? null,
|
||||
customerApproval: perTruck
|
||||
? perTruck.signedAt
|
||||
? {
|
||||
approvedAt: perTruck.signedAt.toISOString(),
|
||||
signerDisplayName: perTruck.signerName ?? '-',
|
||||
signatureImageUrl: null,
|
||||
}
|
||||
: null
|
||||
: this.extractCustomerDeliveryApproval(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -4131,6 +4429,23 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// EDR last-mile: same per-truck rule — each assigned truck is weighed out
|
||||
// separately, and deliver waits until the last one has left.
|
||||
const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
const lmTotal = Number(lm?.total ?? 0);
|
||||
const lmLeft = Number(lm?.left ?? 0);
|
||||
if (lmTotal > 0 && lmLeft < lmTotal) {
|
||||
throw new BadRequestException(
|
||||
`Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const receiverName = dto.receiverName.trim();
|
||||
@@ -4225,6 +4540,12 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
});
|
||||
|
||||
// "Approve delivery" nudge: on Deliver the customer is reminded to sign any
|
||||
// handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
|
||||
if (item.bookingId) {
|
||||
void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -4952,10 +5273,11 @@ export class WarehouseInventoryService {
|
||||
releaseDate: Date | null;
|
||||
trainSchedule: string | null;
|
||||
lastMileDeliveryAddress: string | null;
|
||||
truckPlate?: string | null;
|
||||
customerApproval: {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string;
|
||||
signatureImageUrl: string | null;
|
||||
} | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -5001,6 +5323,7 @@ export class WarehouseInventoryService {
|
||||
['Release Order', data.releaseOrderReference],
|
||||
['Release Date', fmt(data.releaseDate)],
|
||||
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
|
||||
];
|
||||
const approval = data.customerApproval;
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -36,7 +47,10 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
||||
|
||||
/**
|
||||
* Approve-delivery flow: open the handover document for the customer to review,
|
||||
* then apply their saved signature (approve) and hand back the signed PDF.
|
||||
* then sign it with their typed full name (saved signature applied when present).
|
||||
* Self-haul: one booking-level handover, signed once. EDR last-mile: one
|
||||
* handover per delivering truck — the customer signs each; when the last one is
|
||||
* signed the delivery completes automatically.
|
||||
*/
|
||||
export function ApproveDeliveryModal({
|
||||
bookingId,
|
||||
@@ -48,15 +62,33 @@ export function ApproveDeliveryModal({
|
||||
const queryClient = useQueryClient();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: handovers } = useQuery({
|
||||
queryKey: ["booking-handovers", bookingId],
|
||||
queryFn: () => bookingsService.listBookingHandovers(bookingId),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Per-truck mode: any EDR last-mile handover means one signature per truck.
|
||||
const edrMode = (handovers ?? []).some((h) => h.mileType === "EDR_LAST_MILE");
|
||||
const unsigned = (handovers ?? []).filter((h) => !h.signedAt);
|
||||
const selected =
|
||||
(handovers ?? []).find((h) => h.id === selectedId && !h.signedAt) ?? unsigned[0] ?? null;
|
||||
|
||||
const {
|
||||
data: docBlob,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["booking-handover-doc", bookingId],
|
||||
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"],
|
||||
queryFn: () =>
|
||||
bookingsService.downloadBookingHandoverDocument(
|
||||
bookingId,
|
||||
edrMode ? selected?.id : undefined,
|
||||
),
|
||||
enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -70,10 +102,32 @@ export function ApproveDeliveryModal({
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [docBlob]);
|
||||
|
||||
const invalidateBooking = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
|
||||
const onSignError = (error: unknown) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
onClose();
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
onClose();
|
||||
navigate("/billing");
|
||||
}
|
||||
};
|
||||
|
||||
const handoverMutation = useMutation(
|
||||
api.bookings.downloadHandoverDocument.mutationOptions(),
|
||||
);
|
||||
|
||||
// Booking-level (self-haul) approval — signs every handover at once.
|
||||
const approve = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async (result) => {
|
||||
@@ -87,30 +141,35 @@ export function ApproveDeliveryModal({
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.bookings.get.queryKey({ id: bookingId }),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
|
||||
]);
|
||||
await invalidateBooking();
|
||||
onApproved?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = errorMessage(error);
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
onClose();
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
onClose();
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
onError: onSignError,
|
||||
});
|
||||
|
||||
const busy = approve.isPending || handoverMutation.isPending;
|
||||
// Per-truck (EDR last-mile) signature — one handover at a time.
|
||||
const signOne = useMutation({
|
||||
mutationFn: ({ handoverId, name }: { handoverId: string; name: string }) =>
|
||||
bookingsService.signHandover(handoverId, name),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["booking-handovers", bookingId],
|
||||
});
|
||||
setSelectedId(null);
|
||||
if (result.allSigned) {
|
||||
toast.success("All handovers signed — delivery confirmed");
|
||||
await invalidateBooking();
|
||||
onApproved?.();
|
||||
onClose();
|
||||
} else {
|
||||
toast.success("Handover signed — please sign the remaining truck(s)");
|
||||
}
|
||||
},
|
||||
onError: onSignError,
|
||||
});
|
||||
|
||||
const busy = approve.isPending || handoverMutation.isPending || signOne.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -123,12 +182,42 @@ export function ApproveDeliveryModal({
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light" icon={<Info size={16} />}>
|
||||
<Text size="sm">
|
||||
Review the handover document below, then type your full name to sign and
|
||||
confirm you received the goods. Your saved signature is applied automatically
|
||||
if you have one.
|
||||
{edrMode
|
||||
? "Your goods were delivered by EDR truck(s). Review and sign the handover for each truck to confirm you received the goods — delivery completes once every truck is signed."
|
||||
: "Review the handover document below, then type your full name to sign and confirm you received the goods. Your saved signature is applied automatically if you have one."}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{edrMode && (handovers?.length ?? 0) > 0 && (
|
||||
<Stack gap={4}>
|
||||
{handovers!.map((h) => (
|
||||
<UnstyledButton
|
||||
key={h.id}
|
||||
onClick={() => !h.signedAt && setSelectedId(h.id)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border:
|
||||
selected?.id === h.id
|
||||
? "1px solid var(--mantine-color-edr-green-6)"
|
||||
: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: h.signedAt ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{h.truckPlate ? `Truck ${h.truckPlate}` : "Booking handover"} —{" "}
|
||||
{h.reference}
|
||||
</Text>
|
||||
<Badge color={h.signedAt ? "green" : "yellow"} variant="light">
|
||||
{h.signedAt ? `Signed${h.signerName ? ` — ${h.signerName}` : ""}` : "Awaiting signature"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
@@ -170,10 +259,21 @@ export function ApproveDeliveryModal({
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={busy}
|
||||
disabled={isLoading || isError || !signerName.trim()}
|
||||
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isError ||
|
||||
!signerName.trim() ||
|
||||
(edrMode && !selected)
|
||||
}
|
||||
onClick={() =>
|
||||
edrMode && selected
|
||||
? signOne.mutate({ handoverId: selected.id, name: signerName.trim() })
|
||||
: approve.mutate({ id: bookingId, signerName: signerName.trim() })
|
||||
}
|
||||
>
|
||||
Approve & sign delivery
|
||||
{edrMode && selected?.truckPlate
|
||||
? `Sign for truck ${selected.truckPlate}`
|
||||
: "Approve & sign delivery"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse {
|
||||
signerDisplayName: string;
|
||||
}
|
||||
|
||||
/** One import handover record — booking-level or per truck (EDR last-mile). */
|
||||
export interface BookingHandoverRecord {
|
||||
id: string;
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
mileType: "SELF_HAUL" | "EDR_LAST_MILE";
|
||||
generatedAt: string;
|
||||
signedAt: string | null;
|
||||
signerName: string | null;
|
||||
deliveredAt: string | null;
|
||||
}
|
||||
|
||||
export interface SignHandoverResponse {
|
||||
handoverId: string;
|
||||
bookingId: string;
|
||||
signedAt: string | null;
|
||||
signerDisplayName: string;
|
||||
allSigned: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerTruckAssignmentPayload {
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
@@ -206,13 +226,36 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
|
||||
downloadBookingHandoverDocument: async (
|
||||
bookingId: string,
|
||||
handoverId?: string,
|
||||
): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
|
||||
{ responseType: "blob" },
|
||||
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
listBookingHandovers: async (
|
||||
bookingId: string,
|
||||
): Promise<BookingHandoverRecord[]> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/handovers`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
signHandover: async (
|
||||
handoverId: string,
|
||||
signerName: string,
|
||||
): Promise<SignHandoverResponse> => {
|
||||
const { data } = await client.post(
|
||||
`/api/warehouse-inventory/handovers/${handoverId}/sign`,
|
||||
{ signerName },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
|
||||
|
||||
Reference in New Issue
Block a user