feat(warehouses): handover-driven approve delivery, weigh-skip, 5-min sign reminders

Approve delivery now tracks the handover lifecycle exactly: the button appears
(detail page + portal dashboard) the moment a handover is generated and
disappears when the customer signs. The bookings list attaches the
handoverAwaitingSignature flag via one batched query per page; status
heuristics (COMPLETED / TRUCK_ASSIGNED+arrived) are gone.

- generate the arrival handover for ANY self-haul truck: portal-assigned OR
  walk-in registered at the gate (isSelfHaulBooking: assigned_at set, or no
  EDR last-mile leg). Same rule now guards the exit paper.
- remind every 5 minutes (in-app + SMS + email) until the handover is signed
  (@Cron in HandoverService; one reminder per booking per tick)
- Truck Leaving no longer opens blank: the import queue mapper now carries
  inv.notes, so the saved arrival renders read-only with only gate-out time
  and gross weight editable
- container bookings get "Weigh truck? Yes/No": No skips tare/gross and the
  container weight match (weighingSkipped on ReleaseOrderDto, decision made at
  arrival sticks for the exit via the Weighing: SKIPPED note). Bulk always
  weighs, unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-09 15:06:24 +00:00
parent 7bf407b503
commit 7ab5e9edd5
9 changed files with 174 additions and 42 deletions

View File

@@ -1125,6 +1125,30 @@ 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.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
if (!ids.length) return;
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`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'`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
for (const b of bookings) {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -1135,7 +1159,7 @@ export class BookingsService {
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
const result = await this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
@@ -1162,6 +1186,8 @@ export class BookingsService {
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
await this.attachHandoverFlags(result.items ?? []);
return result;
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -90,4 +90,13 @@ export class ReleaseOrderDto {
@IsOptional()
@IsDateString()
gateOutTime?: string;
@ApiPropertyOptional({
description:
'Container bookings only: the operator chose not to weigh this truck. ' +
'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.',
})
@IsOptional()
@IsBoolean()
weighingSkipped?: boolean;
}

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
@@ -150,6 +151,37 @@ export class HandoverService {
);
}
/**
* Reminder loop: until a self-haul handover is signed, re-send the sign
* notification (in-app + SMS + email) every 5 minutes. One reminder per
* booking per tick, newest unsigned handover's reference. Stops the moment
* signForBooking() stamps signed_at.
*
* NB: runs in every API instance — keep a single instance in dev or the
* customer is reminded once per instance per tick.
*/
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
async remindUnsignedHandovers(): Promise<void> {
try {
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
`SELECT DISTINCT ON (booking_id)
booking_id AS "bookingId", reference
FROM freight.booking_handovers
WHERE signed_at IS NULL
AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
ORDER BY booking_id, generated_at DESC`,
);
if (!rows.length) return;
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
for (const row of rows) {
await this.notifySignNeeded(row.bookingId, row.reference);
}
} catch (err) {
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
}
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource

View File

@@ -2357,6 +2357,27 @@ export class WarehouseInventoryService {
});
}
/**
* Self-haul = the customer's own truck collects the goods: either a truck
* assigned via the portal (customer_truck_assigned_at), or a walk-in truck
* registered at the gate on a booking with no EDR last-mile leg. EDR
* last-mile bookings are never self-haul.
*/
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const runner = manager ?? this.dataSource;
const [row]: Array<{ ok: number }> = await runner.query(
`SELECT 1 AS ok
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
AND (b.customer_truck_assigned_at IS NOT NULL
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
AND COALESCE(st.includes_last_mile, false) = false))`,
[bookingId],
);
return Boolean(row);
}
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
@@ -2366,19 +2387,17 @@ export class WarehouseInventoryService {
);
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
// Leaving = gate-out captured, with either a weighed gross or an explicit
// container weighing skip (bulk always weighs).
const isTruckLeaving =
Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
if (item.bookingId) {
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
// Self-haul = customer collects: a truck assigned via the portal, OR a
// walk-in truck registered at the gate on a booking with no EDR last mile.
const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
@@ -2392,7 +2411,8 @@ export class WarehouseInventoryService {
// Authoritative weight match: the truck's net (gross tare) must equal the
// total VGM cargo weight of the containers selected as loaded on it.
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
// Skipped when the operator chose not to weigh (containers only).
if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
@@ -2462,13 +2482,10 @@ export class WarehouseInventoryService {
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
// (idempotent) and notify the customer to sign it. Covers BOTH portal-
// assigned trucks and walk-in trucks registered manually at the gate
// (no portal assignment, no EDR last mile). Must be signed before leaving.
if (await this.isSelfHaulBooking(item.bookingId, manager)) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
@@ -4453,14 +4470,17 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined) {
// Container bookings may skip the weighbridge entirely (weighingSkipped);
// bulk always weighs.
const weighingSkipped = dto.weighingSkipped === true;
if (dto.tareWeight === undefined && !weighingSkipped) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
@@ -4472,7 +4492,11 @@ export class WarehouseInventoryService {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
if (
!weighingSkipped &&
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
grossWeight == null
) {
throw new BadRequestException('Gross weight is required for truck exit');
}
@@ -4488,7 +4512,8 @@ export class WarehouseInventoryService {
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} t`,
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
@@ -4512,6 +4537,8 @@ export class WarehouseInventoryService {
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
// The weigh/skip decision is made at arrival and sticks for the exit.
weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
};
}