Handover SIGN notification, resending until signed no exit before sign

This commit is contained in:
Hagernesh
2026-07-08 09:32:24 +00:00
parent 8de7754e35
commit 80904eeeb2
22 changed files with 703 additions and 215 deletions

View File

@@ -57,6 +57,15 @@ export class CustomerTruckService {
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
// Never assign more trucks than the booking has containers.
const existingTrucks = await this.dataSource
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (existingTrucks + 1 > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
);
}
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);

View File

@@ -0,0 +1,37 @@
import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service';
/**
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
* company's phone/email and sends the message over both channels, swallowing
* per-channel failures so a missing provider never breaks the caller's flow.
*/
export async function sendCompanyChannels(
dataSource: DataSource,
notifications: NotificationsService,
companyId: string,
message: string,
): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query(
`SELECT COALESCE(phone, etrade_phone) AS phone, email
FROM freight.companies
WHERE id = $1 AND deleted_at IS NULL`,
[companyId],
);
if (contact?.phone) {
try {
await notifications.directSend('sms', contact.phone, message);
} catch {
/* best-effort: SMS provider unavailable */
}
}
if (contact?.email) {
try {
await notifications.directSend('email', contact.email, message);
} catch {
/* best-effort: email provider unavailable */
}
}
}

View File

@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
export class CreateWarehouseDto {
@ApiProperty()
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
@IsNumber()
@Min(0)
maxVolume?: number;
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
@IsOptional()
@IsEnum(WAREHOUSE_STATUSES)
status?: WarehouseStatus;
}

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
/**
* Optional explicit storage location. When warehouse/yard/zone are all provided,
* the item is stored there directly; otherwise store() falls back to the
* allocation-rule / capacity-balanced auto pick.
*/
export class StoreInventoryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
UNLOADED_AT_DJIBOUTI_PORT: [],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
// Reserve is retired from the operator flow — a stored export item advances
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
STORED: ['RESERVED', 'READY_FOR_LOADING'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],

View File

@@ -4,6 +4,8 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
@@ -18,6 +20,7 @@ export class HandoverService {
constructor(
private readonly dataSource: DataSource,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
/** Tell the customer a handover is ready and needs their signature. */
@@ -28,15 +31,17 @@ export class HandoverService {
[bookingId],
);
if (!b?.companyId) return;
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Handover — signature needed',
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference },
});
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
} catch (err) {
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
}
@@ -49,6 +54,32 @@ export class HandoverService {
});
}
/**
* Ask the customer to sign the booking's handover. Ensures a handover exists
* (creates a booking-level self-haul one if none yet), then fires the
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
*/
async requestSignature(
bookingId: string,
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
const repo = this.dataSource.getRepository(BookingHandover);
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
if (existing.length === 0) {
// No handover yet (truck not arrived): create a booking-level one so the
// customer has something to sign. ensureForArrivedTruck notifies on create.
const created = await this.ensureForArrivedTruck(bookingId, {});
return { notified: true, reference: created.reference, alreadySigned: false };
}
const unsigned = existing.find((h) => !h.signedAt);
if (!unsigned) {
return { notified: false, reference: existing[0].reference, alreadySigned: true };
}
await this.notifySignNeeded(bookingId, unsigned.reference);
return { notified: true, reference: unsigned.reference, alreadySigned: false };
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction

View File

@@ -1,8 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { FilesService } from '../files/files.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
@Injectable()
export class WarehouseInspectionService {
private readonly logger = new Logger(WarehouseInspectionService.name);
constructor(
private readonly dataSource: DataSource,
private readonly inspectionRepository: WarehouseInspectionRepository,
private readonly filesService: FilesService,
private readonly lastMileService: LastMileService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "companyId",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
// Self-haul import: goods are pickup-ready but no collection truck is
// assigned yet — nudge the customer to assign one from the portal.
void this.notifyTruckAssignmentNeeded(row);
}
}
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
private async notifyTruckAssignmentNeeded(row: {
bookingId?: string | null;
bookingReference?: string | null;
companyId?: string | null;
}): Promise<void> {
if (!row.companyId || !row.bookingId) return;
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
try {
await this.inbox.notify({
recipients: { companyId: row.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Assign a truck for pickup',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
});
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
);
}
}

View File

@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
}
@Post(':id/store')
@ApiOperation({ summary: 'Mark received inventory as STORED' })
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.store(id, performedBy);
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@@ -354,12 +355,24 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@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) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -358,12 +359,14 @@ export interface ImportUnloadedRow {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
notes: string | null;
}
@Injectable()
@@ -403,16 +406,18 @@ export class WarehouseInventoryService {
if (!booking.companyId) return;
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
if (booking.customerTruckAssignedAt) return; // already assigned
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
try {
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Assign a truck for pickup',
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
body,
link: `/bookings/${bookingId}`,
data: { bookingId, action: 'ASSIGN_TRUCK' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
}
@@ -1338,12 +1343,18 @@ export class WarehouseInventoryService {
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
(b.customer_truck_assigned_at IS NOT NULL
OR EXISTS (SELECT 1 FROM freight.last_mile lm
WHERE lm.booking_id = b.id
AND lm.vehicle_id IS NOT NULL
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
inv.notes AS "notes",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -2141,13 +2152,30 @@ export class WarehouseInventoryService {
// ── Lifecycle transitions ────────────────────────────────────────────────
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
async store(
id: string,
performedBy?: string,
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'STORED');
// Explicit location wins when the operator picked warehouse + yard + zone;
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
const manualLocation =
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
? {
warehouseId: chosen.warehouseId,
yardId: chosen.yardId,
zoneId: chosen.zoneId,
path: undefined as string | undefined,
}
: null;
const criteria = await this.getInventoryAllocationCriteria(item);
const ruleLocation = await this.allocation.resolveLocation(criteria);
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
const location =
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
if (!location) {
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
@@ -2191,18 +2219,19 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
}
const storedReason = manualLocation
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
: ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
await manager.getRepository(WarehouseInventory).update(id, {
status: 'STORED',
storedAt: new Date(),
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
notes: this.appendNote(
locked.notes,
ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
),
notes: this.appendNote(locked.notes, storedReason),
});
await this.activityLog.record(
@@ -2210,9 +2239,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_STORED',
inventoryId: id,
warehouseId: location.warehouseId,
description: ruleLocation?.rule
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
description: storedReason.replace(/^Stored/, 'Inventory stored'),
performedBy,
},
manager,
@@ -2331,6 +2358,26 @@ export class WarehouseInventoryService {
'Customer must sign the handover before the exit paper can be generated',
);
}
// 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) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
.filter(Boolean);
if (selected.length) {
const weights = await this.bookingContainerWeights(item.bookingId);
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
throw new BadRequestException(
`Weight mismatch: gross tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
);
}
}
}
}
}
const releaseDate = isTruckLeaving
@@ -2565,6 +2612,7 @@ export class WarehouseInventoryService {
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
}>
> {
const rows: Array<{
@@ -2611,6 +2659,10 @@ export class WarehouseInventoryService {
[bookingId],
);
// Booking-level gate: the per-truck exit paper is blocked until the handover
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
const handoverSigned = await this.handover.isFullySigned(bookingId);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
@@ -2633,6 +2685,32 @@ export class WarehouseInventoryService {
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
handoverSigned,
}));
}
/**
* The booking's containers with their VGM cargo weight (tonnes), keyed by
* container number. Drives the truck-leaving exit weighing: the selected
* containers' total cargo weight must match (gross tare).
*/
async bookingContainerWeights(
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
weightTons: Number(r.weightTons) || 0,
}));
}

View File

@@ -64,8 +64,8 @@ export class WarehousesService {
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
status: dto.status ?? 'ACTIVE',
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
});
} catch (error) {
this.mapDbError(error);