mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user