mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
Handover SIGN notification, resending until signed no exit before sign
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user