mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
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:
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user