mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
dispatch() (LOADED -> DISPATCHED) routed through the shared transition() helper, which only flipped status/timestamp and never called applyCapacityDelta. deliver() (import pickup) already decrements correctly, so export cargo leaving by train inflated currentWeight/ currentVolume/currentContainers forever instead of freeing capacity. Add optional freeCapacity hook to transition(), wire dispatch() to it, mirroring the negative-delta pattern already used in deliver().
6847 lines
296 KiB
TypeScript
6847 lines
296 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import {
|
||
Between,
|
||
DataSource,
|
||
EntityManager,
|
||
FindManyOptions,
|
||
FindOptionsWhere,
|
||
ILike,
|
||
In,
|
||
IsNull,
|
||
LessThanOrEqual,
|
||
MoreThanOrEqual,
|
||
Raw,
|
||
} from 'typeorm';
|
||
|
||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||
import { generateGrnNumber } from '../../common/grn.util';
|
||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||
import { Company } from '../companies/entities/company.entity';
|
||
import { Container } from '../container-management/entities/container.entity';
|
||
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,
|
||
notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared,
|
||
} from '../notifications/notify-company.util';
|
||
import {
|
||
companyNotifyPhoneExpr,
|
||
primaryContactUserJoin,
|
||
} from '../notifications/resolve-company-phone.util';
|
||
import { SignaturesService } from '../signatures/signatures.service';
|
||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||
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 { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||
import {
|
||
WAREHOUSE_INVENTORY_TRANSITIONS,
|
||
WarehouseInventory,
|
||
WarehouseInventoryStatus,
|
||
} from './entities/warehouse-inventory.entity';
|
||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||
import { Warehouse } from './entities/warehouse.entity';
|
||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||
import { HandoverService } from './handover.service';
|
||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||
|
||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||
|
||
const normalizeWagonStatus = (status: string | null | undefined) =>
|
||
(status ?? '')
|
||
.trim()
|
||
.replace(/[\s-]+/g, '_')
|
||
.toUpperCase();
|
||
|
||
const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||
|
||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||
|
||
export interface InventoryInquiryResult {
|
||
id: string;
|
||
inventoryId: string | null;
|
||
bookingId: string | null;
|
||
bookingReference: string | null;
|
||
bookingNumber: string | null;
|
||
bookingStatus: string | null;
|
||
customerName: string | null;
|
||
containerNumber: string | null;
|
||
cargoType: string | null;
|
||
cargoDescription: string | null;
|
||
goodsId: string | null;
|
||
warehouse: { id: string; name: string; code: string } | null;
|
||
yard: { id: string; name: string; code: string } | null;
|
||
zone: { id: string; name: string; code: string } | null;
|
||
status: string | null;
|
||
trainNumber: string | null;
|
||
trainStatus: string | null;
|
||
route: string | null;
|
||
locationSummary: string | null;
|
||
quantity: number;
|
||
weight: number;
|
||
arrivedAt: Date | null;
|
||
readyForLoadingAt: Date | null;
|
||
}
|
||
|
||
interface BookingSummaryRow {
|
||
id: string;
|
||
reference: string | null;
|
||
status: string | null;
|
||
customer: string | null;
|
||
}
|
||
|
||
interface ArrivalQueueRow {
|
||
bookingId: string;
|
||
bookingReference: string | null;
|
||
customer: string | null;
|
||
cargo: string | null;
|
||
container: string | null;
|
||
arrivalDate: Date | null;
|
||
bookingStatus: string | null;
|
||
inventoryId: string | null;
|
||
currentStatus: string | null;
|
||
inspectionStatus: string | null;
|
||
facility: string | null;
|
||
warehouse: string | null;
|
||
yard: string | null;
|
||
zone: string | null;
|
||
}
|
||
|
||
export interface ArrivalQueueItem {
|
||
bookingId: string;
|
||
bookingReference: string | null;
|
||
customer: string | null;
|
||
cargo: string | null;
|
||
container: string | null;
|
||
facility: string | null;
|
||
warehouse: string | null;
|
||
yard: string | null;
|
||
zone: string | null;
|
||
inventoryId: string | null;
|
||
currentStatus: string | null;
|
||
arrivalDate: Date | null;
|
||
inspectionStatus: string | null;
|
||
unloaded: boolean;
|
||
}
|
||
|
||
interface DefaultLocation {
|
||
warehouseId: string;
|
||
facilityId?: string | null;
|
||
yardId: string;
|
||
zoneId: string;
|
||
}
|
||
|
||
interface StorageAllocationLocation extends DefaultLocation {
|
||
path?: string | null;
|
||
rule?: { id: string; name: string; storageType: string | null } | null;
|
||
}
|
||
|
||
interface InventoryAllocationCriteria {
|
||
freightType?: string | null;
|
||
tradeDirection?: string | null;
|
||
cargoTypeCode?: string | null;
|
||
containerStatus?: string | null;
|
||
requiresInspection?: boolean | null;
|
||
}
|
||
|
||
export interface AutoUnloadResult {
|
||
processedCount: number;
|
||
skippedCount: number;
|
||
failedCount: number;
|
||
results: Array<{
|
||
bookingId: string;
|
||
inventoryId?: string;
|
||
status: 'PROCESSED' | 'FAILED';
|
||
reason?: string;
|
||
}>;
|
||
}
|
||
|
||
export interface AutoLoadResult {
|
||
loadedCount: number;
|
||
skippedCount: number;
|
||
results: Array<{
|
||
inventoryId: string;
|
||
status: 'LOADED' | 'SKIPPED';
|
||
reason?: string;
|
||
}>;
|
||
}
|
||
|
||
interface WarehouseDashboardSummary {
|
||
totalWarehouses: number;
|
||
totalInventory: number;
|
||
receivedToday: number;
|
||
stored: number;
|
||
reserved: number;
|
||
readyForLoading: number;
|
||
loaded: number;
|
||
dispatched: number;
|
||
}
|
||
|
||
interface LocationRef {
|
||
warehouseId: string;
|
||
yardId: string;
|
||
zoneId: string;
|
||
}
|
||
|
||
interface BookingUnloadLocation extends LocationRef {
|
||
bookingId: string;
|
||
}
|
||
|
||
interface LocationNode {
|
||
capacityWeight?: number | null;
|
||
capacityContainers?: number | null;
|
||
currentWeight: number;
|
||
maxWeight?: number | null;
|
||
maxVolume?: number | null;
|
||
currentVolume?: number | null;
|
||
currentContainers: number;
|
||
}
|
||
|
||
// ── Receive (Import/Export bulk) shapes ──────────────────────────────────────
|
||
export interface EligibleBookingRow {
|
||
id: string;
|
||
reference: string;
|
||
customerId: string | null;
|
||
customer: string | null;
|
||
customerTin: string | null;
|
||
customerPhone: string | null;
|
||
containerNumber: string | null;
|
||
sealNumbers: string | null;
|
||
containerQuantity: number | null;
|
||
containerPackagingType: string | null;
|
||
cargoDescription: string | null;
|
||
lastMileRequested: boolean;
|
||
direction: string;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
freightType: string | null;
|
||
cargo: string | null;
|
||
weight: string | null;
|
||
paymentStatus: string;
|
||
status: string;
|
||
hasFirstMile: boolean;
|
||
firstMileRequestId: string | null;
|
||
firstMileStatus: string | null;
|
||
firstMileVehicleId: string | null;
|
||
firstMileTruckPlateNumber: string | null;
|
||
firstMileTrailerPlateNumber: string | null;
|
||
firstMileDriverName: string | null;
|
||
firstMileDriverPhone: string | null;
|
||
firstMileDriverLicenseNumber: string | null;
|
||
firstMileTruckType: string | null;
|
||
customerTruckPlateNumber: string | null;
|
||
customerTruckDriverName: string | null;
|
||
customerTruckType: string | null;
|
||
customerTruckContainerNumber: string | null;
|
||
customerTruckAssignedAt: string | null;
|
||
}
|
||
|
||
export interface BulkReceiveResult {
|
||
receivedCount: number;
|
||
skippedCount: number;
|
||
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
||
}
|
||
|
||
|
||
export interface BulkInspectResult {
|
||
inspectedCount: number;
|
||
skippedCount: number;
|
||
results: { inventoryId: string; status: string; reason?: string }[];
|
||
}
|
||
|
||
export interface ReadyToLoadRow {
|
||
id: string;
|
||
bookingId: string | null;
|
||
bookingReference: string | null;
|
||
customerId: string | null;
|
||
customerName: string | null;
|
||
containerNumber: string | null;
|
||
cargoType: string | null;
|
||
weight: number | null;
|
||
grnNumber: string | null;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
inspectionStatus: string | null;
|
||
status: string;
|
||
}
|
||
|
||
export interface BulkDispatchResult {
|
||
dispatchedCount: number;
|
||
skippedCount: number;
|
||
results: { inventoryId: string; status: string; reason?: string }[];
|
||
}
|
||
|
||
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
|
||
export interface LoadableTrainRow {
|
||
scheduleId: string;
|
||
trainNumber: string | null;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
status: string;
|
||
departureTime: string | Date | null;
|
||
/** Received/ready inventory not yet loaded onto this train. */
|
||
readyCount: number;
|
||
/** Inventory already loaded onto this train. */
|
||
loadedCount: number;
|
||
}
|
||
|
||
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
|
||
export interface TrainLoadableItemRow {
|
||
id: string;
|
||
bookingId: string | null;
|
||
bookingReference: string | null;
|
||
customerName: string | null;
|
||
containerNumber: string | null;
|
||
cargoType: string | null;
|
||
weight: number | null;
|
||
grnNumber: string | null;
|
||
inspectionStatus: string | null;
|
||
status: string;
|
||
wagonId: string | null;
|
||
wagonNumber: string | null;
|
||
sequenceNo: number | null;
|
||
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
|
||
loadable: boolean;
|
||
}
|
||
|
||
export interface TrainLoadResult {
|
||
loadedCount: number;
|
||
skippedCount: number;
|
||
results: { inventoryId: string; status: string; reason?: string }[];
|
||
}
|
||
|
||
export interface AutoUnloadArrivedResult {
|
||
unloadedCount: number;
|
||
skippedCount: number;
|
||
failedCount: number;
|
||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||
}
|
||
|
||
export interface AutoUnloadExportDjiboutiResult {
|
||
unloadedCount: number;
|
||
skippedCount: number;
|
||
failedCount: number;
|
||
interchangeDocument?: Pick<InterchangeDocument, 'id' | 'documentNo' | 'status'>;
|
||
results: Array<{
|
||
bookingId: string;
|
||
itemType: 'CONTAINER' | 'CARGO';
|
||
itemId?: string | null;
|
||
inventoryId?: string;
|
||
containerNumber?: string | null;
|
||
status: string;
|
||
message?: string;
|
||
reason?: string;
|
||
}>;
|
||
}
|
||
|
||
export interface ImportUnloadedRow {
|
||
id: string;
|
||
bookingId: string | null;
|
||
bookingReference: string | null;
|
||
customerId: string | null;
|
||
customerName: string | null;
|
||
arrivalTime: string | null;
|
||
containerNumber: string | null;
|
||
cargoType: string | null;
|
||
weight: number | null;
|
||
grnNumber: string | null;
|
||
trainSchedule: string | null;
|
||
inspectionStatus: string | null;
|
||
pickupOption: string;
|
||
lastMileRequested: boolean;
|
||
customerTruckPlateNumber: string | null;
|
||
customerTruckDriverName: string | null;
|
||
customerTruckType: string | null;
|
||
customerTruckContainerNumber: string | null;
|
||
customerTruckAssignedAt: string | null;
|
||
hasAssignedTruck: boolean;
|
||
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||
doubleHandling: boolean | null;
|
||
currentStatus: string;
|
||
releaseDate: string | null;
|
||
releaseOrderReference: string | null;
|
||
handoverDocumentReference: string | null;
|
||
handoverDocumentDate: string | null;
|
||
deliveredAt: string | null;
|
||
notes: string | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class WarehouseInventoryService {
|
||
private readonly logger = new Logger(WarehouseInventoryService.name);
|
||
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||
private readonly loadingRepository: WarehouseLoadingRepository,
|
||
private readonly activityLog: WarehouseActivityLogService,
|
||
private readonly scheduling: SchedulingReadFacade,
|
||
private readonly allocation: WarehouseAllocationService,
|
||
private readonly invoices: WarehouseInvoiceService,
|
||
private readonly inspectionService: WarehouseInspectionService,
|
||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||
private readonly lastMileService: LastMileService,
|
||
private readonly notifications: NotificationsService,
|
||
private readonly signatures: SignaturesService,
|
||
private readonly handover: HandoverService,
|
||
private readonly inbox: NotificationInboxService,
|
||
private readonly events: EventEmitter2,
|
||
private readonly stampSettings: StampSettingsService,
|
||
private readonly logoSettings: LogoSettingsService,
|
||
) {}
|
||
|
||
/**
|
||
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||
*/
|
||
/**
|
||
* At-a-glance warehouse ops counters for the KPI strip:
|
||
* - receivedToday: items received today
|
||
* - pendingInspection: RECEIVED items not yet inspected
|
||
* - trucksOnSite: customer trucks arrived but not departed
|
||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||
*/
|
||
/**
|
||
* Every truck currently inside the yard, across all bookings — the list behind
|
||
* the `trucksOnSite` figure on the ops dashboard, which until now could only
|
||
* be counted and never opened.
|
||
*
|
||
* Covers both haulage paths because the gate does: a customer's own truck and
|
||
* an EDR last-mile truck arrive at the same barrier and need the same paper.
|
||
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
|
||
* what is coming as well as what is here — an assigned truck only stamps
|
||
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
|
||
* it departs.
|
||
*/
|
||
async trucksOnSite(): Promise<
|
||
Array<{
|
||
source: 'CUSTOMER' | 'EDR';
|
||
assignmentId: string;
|
||
status: 'INBOUND' | 'ON_SITE';
|
||
plateNumber: string | null;
|
||
driverName: string | null;
|
||
truckType: string | null;
|
||
arrivedAt: string | null;
|
||
bookingId: string;
|
||
bookingReference: string | null;
|
||
customerName: string | null;
|
||
containers: string | null;
|
||
}>
|
||
> {
|
||
return this.dataSource.query(
|
||
`SELECT 'CUSTOMER' AS "source",
|
||
a.id AS "assignmentId",
|
||
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||
a.plate_number AS "plateNumber",
|
||
a.driver_name AS "driverName",
|
||
a.truck_type AS "truckType",
|
||
a.arrived_at AS "arrivedAt",
|
||
b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
company.name AS "customerName",
|
||
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
|
||
FROM freight.customer_truck_containers c
|
||
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
|
||
FROM freight.customer_truck_assignments a
|
||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE a.deleted_at IS NULL
|
||
AND a.departed_at IS NULL
|
||
|
||
UNION ALL
|
||
|
||
SELECT 'EDR' AS "source",
|
||
va.id AS "assignmentId",
|
||
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
|
||
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
|
||
v.vehicle_type AS "truckType",
|
||
va.arrived_at AS "arrivedAt",
|
||
b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
company.name AS "customerName",
|
||
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
|
||
FROM freight.last_mile_vehicle_containers lvc
|
||
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
|
||
FROM freight.last_mile_vehicle_assignments va
|
||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE va.deleted_at IS NULL
|
||
AND va.departed_at IS NULL
|
||
|
||
-- On-site trucks first, each group oldest-arrival first; inbound trucks
|
||
-- (null arrival) sort to the end.
|
||
ORDER BY "arrivedAt" ASC NULLS LAST`,
|
||
);
|
||
}
|
||
|
||
async opsStats(): Promise<{
|
||
receivedToday: number;
|
||
receivedYesterday: number;
|
||
pendingInspection: number;
|
||
trucksOnSite: number;
|
||
itemsAging: number;
|
||
}> {
|
||
const [row]: Array<{
|
||
receivedToday: number;
|
||
receivedYesterday: number;
|
||
pendingInspection: number;
|
||
trucksOnSite: number;
|
||
itemsAging: number;
|
||
}> = await this.dataSource.query(
|
||
`SELECT
|
||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
|
||
((SELECT count(*)::int FROM freight.customer_truck_assignments a
|
||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
|
||
+
|
||
(SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va
|
||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
|
||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL
|
||
AND status = ANY($1)
|
||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||
[this.IN_WAREHOUSE_STATUSES],
|
||
);
|
||
return {
|
||
receivedToday: row?.receivedToday ?? 0,
|
||
receivedYesterday: row?.receivedYesterday ?? 0,
|
||
pendingInspection: row?.pendingInspection ?? 0,
|
||
trucksOnSite: row?.trucksOnSite ?? 0,
|
||
itemsAging: row?.itemsAging ?? 0,
|
||
};
|
||
}
|
||
|
||
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
|
||
'RECEIVED',
|
||
'UNLOADED',
|
||
'STORED',
|
||
'RESERVED',
|
||
'READY_FOR_LOADING',
|
||
'READY_FOR_PICKUP',
|
||
];
|
||
|
||
/**
|
||
* Dwell time of items still in the warehouse: average days held plus a count
|
||
* per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
|
||
* back to created_at). Powers the dwell / aging histogram.
|
||
*/
|
||
async dwellStats(): Promise<{
|
||
avgDwellDays: number;
|
||
inWarehouseCount: number;
|
||
buckets: Array<{ key: string; label: string; count: number }>;
|
||
}> {
|
||
const [row]: Array<{
|
||
avgDwellDays: number | null;
|
||
inWarehouseCount: number;
|
||
b0: number;
|
||
b1: number;
|
||
b2: number;
|
||
b3: number;
|
||
}> = await this.dataSource.query(
|
||
`WITH held AS (
|
||
SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL
|
||
AND status = ANY($1)
|
||
)
|
||
SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
|
||
count(*)::int AS "inWarehouseCount",
|
||
count(*) FILTER (WHERE age_days < 4)::int AS b0,
|
||
count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
|
||
count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
|
||
count(*) FILTER (WHERE age_days >= 15)::int AS b3
|
||
FROM held`,
|
||
[this.IN_WAREHOUSE_STATUSES],
|
||
);
|
||
return {
|
||
avgDwellDays: row?.avgDwellDays ?? 0,
|
||
inWarehouseCount: row?.inWarehouseCount ?? 0,
|
||
buckets: [
|
||
{ key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
|
||
{ key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
|
||
{ key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
|
||
{ key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Average stage cycle times over items dispatched in the last 90 days:
|
||
* arrived→ready, ready→loaded, loaded→dispatched, and the total
|
||
* arrived→dispatched (dock-to-dispatch). Days, to one decimal.
|
||
*/
|
||
async cycleStats(): Promise<{
|
||
sampleSize: number;
|
||
avgDockToDispatchDays: number;
|
||
stages: Array<{ key: string; label: string; avgDays: number }>;
|
||
}> {
|
||
const gapDays = (from: string, to: string) =>
|
||
`round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
|
||
const [row]: Array<{
|
||
sampleSize: number;
|
||
total: number | null;
|
||
arrivedReady: number | null;
|
||
readyLoaded: number | null;
|
||
loadedDispatched: number | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT count(*)::int AS "sampleSize",
|
||
${gapDays('arrived_at', 'dispatched_at')} AS "total",
|
||
${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
|
||
${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
|
||
${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL
|
||
AND arrived_at IS NOT NULL
|
||
AND dispatched_at IS NOT NULL
|
||
AND dispatched_at > now() - interval '90 days'`,
|
||
);
|
||
return {
|
||
sampleSize: row?.sampleSize ?? 0,
|
||
avgDockToDispatchDays: row?.total ?? 0,
|
||
stages: [
|
||
{ key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
|
||
{ key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
|
||
{ key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Gate / dock throughput: items cleared through the gate today, the average
|
||
* arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
|
||
* bucketed per hour over the last 24 hours. Powers the gate throughput card.
|
||
*/
|
||
async gateStats(): Promise<{
|
||
clearedToday: number;
|
||
avgTurnaroundHours: number | null;
|
||
byHour: Array<{ hour: string; count: number }>;
|
||
}> {
|
||
const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT
|
||
count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
|
||
round(
|
||
avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
|
||
FILTER (
|
||
WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
|
||
AND gate_cleared_at > now() - interval '30 days'
|
||
)::numeric,
|
||
1
|
||
)::float8 AS "avgTurnaroundHours"
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL`,
|
||
);
|
||
const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
|
||
`WITH hours AS (
|
||
SELECT gs AS h
|
||
FROM generate_series(
|
||
date_trunc('hour', now()) - interval '23 hours',
|
||
date_trunc('hour', now()),
|
||
interval '1 hour'
|
||
) gs
|
||
)
|
||
SELECT to_char(hours.h, 'HH24:00') AS hour,
|
||
COALESCE(g.cnt, 0)::int AS count
|
||
FROM hours
|
||
LEFT JOIN (
|
||
SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
|
||
GROUP BY 1
|
||
) g ON g.ph = hours.h
|
||
ORDER BY hours.h`,
|
||
);
|
||
return {
|
||
clearedToday: scalar?.clearedToday ?? 0,
|
||
avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
|
||
byHour,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Received-vs-dispatched throughput as a server-side time series. Buckets by
|
||
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
|
||
* generate_series so empty periods still return a zero row — replaces the
|
||
* client-side approach that downloaded the whole inventory to bucket it.
|
||
*/
|
||
async throughput(
|
||
granularity: 'week' | 'month' | 'year' = 'month',
|
||
): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
|
||
// Whitelist the unit — it is interpolated into date_trunc / interval literals.
|
||
const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
|
||
? granularity
|
||
: 'month';
|
||
const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
|
||
|
||
const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
|
||
await this.dataSource.query(
|
||
`WITH periods AS (
|
||
SELECT gs AS period_start
|
||
FROM generate_series(
|
||
date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
|
||
date_trunc('${unit}', now()),
|
||
'1 ${unit}'::interval
|
||
) gs
|
||
)
|
||
SELECT p.period_start AS "periodStart",
|
||
COALESCE(r.cnt, 0)::int AS received,
|
||
COALESCE(d.cnt, 0)::int AS dispatched
|
||
FROM periods p
|
||
LEFT JOIN (
|
||
SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
|
||
GROUP BY 1
|
||
) r ON r.ps = p.period_start
|
||
LEFT JOIN (
|
||
SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
|
||
FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
|
||
GROUP BY 1
|
||
) d ON d.ps = p.period_start
|
||
ORDER BY p.period_start`,
|
||
[back],
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||
* occupancy heatmap. Optionally scoped to one yard.
|
||
*/
|
||
async zoneOccupancy(yardId?: string): Promise<
|
||
Array<{
|
||
id: string;
|
||
name: string;
|
||
code: string;
|
||
type: string;
|
||
yardId: string;
|
||
capacityWeight: number | null;
|
||
capacityContainers: number | null;
|
||
usedWeight: number;
|
||
usedItems: number;
|
||
occupancyPct: number | null;
|
||
}>
|
||
> {
|
||
const rows: Array<{
|
||
id: string;
|
||
name: string;
|
||
code: string;
|
||
type: string;
|
||
yardId: string;
|
||
capacityWeight: string | null;
|
||
capacityContainers: number | null;
|
||
usedWeight: number;
|
||
usedItems: number;
|
||
}> = await this.dataSource.query(
|
||
`SELECT z.id,
|
||
z.name,
|
||
z.code,
|
||
z.type,
|
||
z.yard_id AS "yardId",
|
||
z.capacity_weight AS "capacityWeight",
|
||
z.capacity_containers AS "capacityContainers",
|
||
COALESCE(SUM(inv.weight) FILTER (
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
|
||
), 0)::float8 AS "usedWeight",
|
||
COALESCE(COUNT(inv.id) FILTER (
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
|
||
), 0)::int AS "usedItems"
|
||
FROM freight.warehouse_zones z
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
|
||
WHERE z.is_active = true
|
||
AND z.deleted_at IS NULL
|
||
AND ($1::uuid IS NULL OR z.yard_id = $1)
|
||
GROUP BY z.id
|
||
ORDER BY z.name`,
|
||
[yardId ?? null],
|
||
);
|
||
|
||
return rows.map((r) => {
|
||
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
||
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
|
||
// used weight to tonnes before comparing so weight occupancy is correct.
|
||
const usedWeightTons = r.usedWeight / 1000;
|
||
const byWeight =
|
||
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
|
||
const byItems =
|
||
r.capacityContainers && r.capacityContainers > 0
|
||
? (r.usedItems / r.capacityContainers) * 100
|
||
: null;
|
||
// Container zones use item-count occupancy; bulk zones (no container cap)
|
||
// fall back to the now unit-correct weight occupancy.
|
||
const pct = byItems ?? byWeight;
|
||
return {
|
||
id: r.id,
|
||
name: r.name,
|
||
code: r.code,
|
||
type: r.type,
|
||
yardId: r.yardId,
|
||
capacityWeight: capWeight,
|
||
capacityContainers: r.capacityContainers,
|
||
usedWeight: r.usedWeight,
|
||
usedItems: r.usedItems,
|
||
occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
|
||
* collection truck while their goods are still in the warehouse
|
||
* (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
|
||
* assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
|
||
*/
|
||
@Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
|
||
async remindImportTruckAssignment(): Promise<void> {
|
||
try {
|
||
const rows: Array<{
|
||
bookingId: string;
|
||
companyId: string | null;
|
||
reference: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT DISTINCT b.id AS "bookingId",
|
||
b.company_id AS "companyId",
|
||
b.reference
|
||
FROM freight.warehouse_inventory inv
|
||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status = 'READY_FOR_PICKUP'
|
||
AND b.trade_direction = 'IMPORT'
|
||
AND b.customer_truck_assigned_at IS NULL
|
||
AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
|
||
);
|
||
if (!rows.length) return;
|
||
this.logger.log(
|
||
`Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
|
||
);
|
||
for (const row of rows) {
|
||
await this.notifyTruckAssignmentNeeded(
|
||
{
|
||
companyId: row.companyId,
|
||
reference: row.reference,
|
||
hasFirstMile: false,
|
||
hasLastMile: false,
|
||
customerTruckAssignedAt: null,
|
||
},
|
||
row.bookingId,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Import truck-assignment reminder tick failed: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async notifyTruckAssignmentNeeded(booking: {
|
||
companyId?: string | null;
|
||
reference?: string | null;
|
||
hasFirstMile?: boolean;
|
||
hasLastMile?: boolean;
|
||
customerTruckAssignedAt?: string | null;
|
||
}, bookingId: string): Promise<void> {
|
||
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,
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Batch 6 — final terminal release / gate clearance.
|
||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||
* inspection / storage / loading steps — only the final release.
|
||
*/
|
||
async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||
const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||
`SELECT id, warehouse_id AS "warehouseId"
|
||
FROM freight.warehouse_inventory
|
||
WHERE id = $1 AND deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[id],
|
||
);
|
||
if (!item) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
|
||
const blocking = await this.invoices.findBlockingInvoice(id);
|
||
if (blocking) {
|
||
throw new BadRequestException(
|
||
'Warehouse demurrage/storage fee must be paid before terminal release.',
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query(
|
||
`SELECT EXISTS (
|
||
SELECT 1
|
||
FROM information_schema.columns
|
||
WHERE table_schema = 'freight'
|
||
AND table_name = 'warehouse_inventory'
|
||
AND column_name = 'gate_cleared_at'
|
||
) AS "exists"`,
|
||
);
|
||
if (gateColumn?.exists) {
|
||
await this.dataSource.query(
|
||
`UPDATE freight.warehouse_inventory
|
||
SET gate_cleared_at = $2,
|
||
release_date = COALESCE(release_date, $2),
|
||
updated_at = now()
|
||
WHERE id = $1 AND deleted_at IS NULL`,
|
||
[id, now],
|
||
);
|
||
} else {
|
||
await this.dataSource.query(
|
||
`UPDATE freight.warehouse_inventory
|
||
SET release_date = COALESCE(release_date, $2),
|
||
updated_at = now()
|
||
WHERE id = $1 AND deleted_at IS NULL`,
|
||
[id, now],
|
||
);
|
||
}
|
||
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_DISPATCHED',
|
||
inventoryId: id,
|
||
warehouseId: item.warehouseId,
|
||
description: 'Gate clearance / terminal release',
|
||
performedBy,
|
||
});
|
||
return this.findById(id);
|
||
}
|
||
|
||
// ── Listing ────────────────────────────────────────────────────────────
|
||
|
||
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||
const createdAt =
|
||
filter.dateFrom && filter.dateTo
|
||
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
|
||
: filter.dateFrom
|
||
? MoreThanOrEqual(new Date(filter.dateFrom))
|
||
: filter.dateTo
|
||
? LessThanOrEqual(new Date(filter.dateTo))
|
||
: undefined;
|
||
|
||
const base: FindOptionsWhere<WarehouseInventory> = {
|
||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||
...(filter.cargoId ? { cargoId: filter.cargoId } : {}),
|
||
...(filter.containerId ? { containerId: filter.containerId } : {}),
|
||
...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
|
||
...(filter.status ? { status: filter.status } : {}),
|
||
...(createdAt ? { createdAt } : {}),
|
||
...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
|
||
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
|
||
};
|
||
|
||
// KPI drill-down filters — predicates mirror opsStats() exactly so the
|
||
// dashboard card's count equals the length of the list it opens.
|
||
if (filter.receivedToday) {
|
||
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
|
||
}
|
||
if (filter.pendingInspection) {
|
||
base.status = 'RECEIVED';
|
||
base.inspectionStatus = IsNull();
|
||
}
|
||
if (filter.agingOverDays) {
|
||
if (!filter.status && !filter.pendingInspection) {
|
||
base.status = In(this.IN_WAREHOUSE_STATUSES);
|
||
}
|
||
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
|
||
days: filter.agingOverDays,
|
||
});
|
||
}
|
||
|
||
const search = filter.search?.trim();
|
||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||
? [
|
||
{ ...base, notes: ILike(`%${search}%`) },
|
||
{ ...base, grnNumber: ILike(`%${search}%`) },
|
||
]
|
||
: base;
|
||
|
||
const items = await this.inventoryRepository.findAll({
|
||
where,
|
||
relations: { warehouse: true, yard: true, zone: true },
|
||
order: { createdAt: 'DESC' },
|
||
});
|
||
await this.attachBookingSummaries(items);
|
||
return items;
|
||
}
|
||
|
||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||
}
|
||
|
||
/**
|
||
* Warehouse location rows for one booking, trimmed for the customer portal:
|
||
* no staff guard on the route, so only location fields leave the API —
|
||
* never notes, fees or inspection internals.
|
||
*/
|
||
async bookingLocation(bookingId: string) {
|
||
const items = await this.inventoryRepository.findAll({
|
||
where: { bookingId },
|
||
relations: { warehouse: true, yard: true, zone: true },
|
||
order: { createdAt: 'DESC' },
|
||
});
|
||
return items.map((i) => ({
|
||
id: i.id,
|
||
bookingId: i.bookingId,
|
||
status: i.status,
|
||
arrivedAt: i.arrivedAt ?? null,
|
||
warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null,
|
||
yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null,
|
||
zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null,
|
||
}));
|
||
}
|
||
|
||
async findById(id: string): Promise<WarehouseInventory> {
|
||
const item = await this.inventoryRepository.findById(id, {
|
||
relations: { warehouse: { facility: true }, yard: true, zone: true },
|
||
});
|
||
|
||
if (!item) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
|
||
return item;
|
||
}
|
||
|
||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||
|
||
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
||
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED'];
|
||
|
||
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
||
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||
const rows: ArrivalQueueRow[] = await this.dataSource.query(
|
||
`SELECT b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
company.name AS "customer",
|
||
b.cargo_free_text AS "cargo",
|
||
ct.container_number AS "container",
|
||
b.scheduled_date AS "arrivalDate",
|
||
b.status AS "bookingStatus",
|
||
inv.id AS "inventoryId",
|
||
inv.status AS "currentStatus",
|
||
inv.inspection_status AS "inspectionStatus",
|
||
fac.name AS "facility",
|
||
wh.name AS "warehouse",
|
||
yard.name AS "yard",
|
||
zone.name AS "zone"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||
LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id
|
||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL
|
||
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||
[this.ARRIVED_BOOKING_STATUSES],
|
||
);
|
||
|
||
return rows.map((r) => ({
|
||
bookingId: r.bookingId,
|
||
bookingReference: r.bookingReference,
|
||
customer: r.customer ?? null,
|
||
cargo: r.cargo ?? null,
|
||
container: r.container ?? null,
|
||
facility: r.facility ?? null,
|
||
warehouse: r.warehouse ?? null,
|
||
yard: r.yard ?? null,
|
||
zone: r.zone ?? null,
|
||
inventoryId: r.inventoryId ?? null,
|
||
currentStatus: r.currentStatus ?? null,
|
||
arrivalDate: r.arrivalDate ?? null,
|
||
inspectionStatus: r.inspectionStatus ?? null,
|
||
unloaded: Boolean(r.inventoryId),
|
||
}));
|
||
}
|
||
|
||
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
|
||
const params = warehouseId ? [warehouseId] : [];
|
||
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||
yard.id AS "yardId", zone.id AS "zoneId"
|
||
FROM freight.warehouses wh
|
||
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||
WHERE wh.deleted_at IS NULL
|
||
${warehouseId ? 'AND wh.id = $1' : ''}
|
||
ORDER BY wh.created_at ASC
|
||
LIMIT 1`,
|
||
params,
|
||
);
|
||
return row ?? null;
|
||
}
|
||
|
||
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||
const arrived: {
|
||
id: string;
|
||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||
customer: string | null;
|
||
weight: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
cargoTypeCode: string | null;
|
||
}[] = await this.dataSource.query(
|
||
`SELECT b.id,
|
||
-- Received weight must land on the inventory row: a booking with no
|
||
-- declared VGM still has per-container VGM to record.
|
||
COALESCE(
|
||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||
(SELECT SUM(bcu.vgm_tons)
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc2
|
||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||
) AS weight,
|
||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||
cgt.code AS "cargoTypeCode",
|
||
company.name AS customer
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||
[this.ARRIVED_BOOKING_STATUSES],
|
||
);
|
||
|
||
const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||
|
||
if (arrived.length === 0) return result;
|
||
|
||
const fallback = await this.pickDefaultLocation();
|
||
|
||
for (const booking of arrived) {
|
||
try {
|
||
// Deterministic allocation by rules; fall back to default location if no rule resolves.
|
||
const allocated = await this.allocation.resolveLocation({
|
||
freightType: booking.freightType,
|
||
tradeDirection: booking.tradeDirection,
|
||
cargoTypeCode: booking.cargoTypeCode,
|
||
});
|
||
const location = allocated ?? fallback;
|
||
if (!location) {
|
||
result.failedCount += 1;
|
||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||
continue;
|
||
}
|
||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
|
||
// onto a train without one. Import GRN handling is left untouched.
|
||
const saved = await this.inventoryRepository.create({
|
||
warehouseId: location.warehouseId,
|
||
yardId: location.yardId,
|
||
zoneId: location.zoneId,
|
||
bookingId: booking.id,
|
||
quantity: 1,
|
||
weight: Number(booking.weight) || 0,
|
||
status: 'RECEIVED',
|
||
arrivedAt: new Date(),
|
||
...(booking.tradeDirection === 'EXPORT'
|
||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) }
|
||
: {}),
|
||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||
});
|
||
result.processedCount += 1;
|
||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
|
||
} catch (error) {
|
||
result.failedCount += 1;
|
||
result.results.push({
|
||
bookingId: booking.id,
|
||
status: 'FAILED',
|
||
reason: error instanceof Error ? error.message : String(error),
|
||
});
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||
// gets one on unload — import as well as export. The direction only decides
|
||
// the GRN prefix, not whether one is issued.
|
||
// The GRN is mapped to the goods owner (the booking's company), so pull it
|
||
// alongside the direction rather than issuing an owner-less number.
|
||
const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT b.trade_direction AS "tradeDirection",
|
||
company.name AS "ownerName"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||
const ownerName = bookingRow?.ownerName ?? null;
|
||
|
||
let location: DefaultLocation | null =
|
||
dto.warehouseId && dto.yardId && dto.zoneId
|
||
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||
: null;
|
||
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
|
||
if (!location) location = await this.pickDefaultLocation();
|
||
if (!location) {
|
||
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||
}
|
||
|
||
const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date();
|
||
|
||
if (existing[0]) {
|
||
await this.inventoryRepository.update(existing[0].id, {
|
||
warehouseId: location.warehouseId,
|
||
yardId: location.yardId,
|
||
zoneId: location.zoneId,
|
||
status: 'RECEIVED',
|
||
arrivedAt,
|
||
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||
...(existing[0].grnNumber
|
||
? {}
|
||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
|
||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||
});
|
||
return this.findById(existing[0].id);
|
||
}
|
||
|
||
const saved = await this.inventoryRepository.create({
|
||
warehouseId: location.warehouseId,
|
||
yardId: location.yardId,
|
||
zoneId: location.zoneId,
|
||
bookingId,
|
||
quantity: 1,
|
||
weight: 0,
|
||
status: 'RECEIVED',
|
||
arrivedAt,
|
||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
|
||
notes: dto.notes ?? 'Unloaded',
|
||
});
|
||
return this.findById(saved.id);
|
||
}
|
||
|
||
/** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
|
||
async autoLoadReady(): Promise<AutoLoadResult> {
|
||
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
||
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||
|
||
for (const item of ready) {
|
||
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
||
if (bookingStatus !== 'PAID') {
|
||
result.skippedCount += 1;
|
||
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
|
||
continue;
|
||
}
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||
status: 'LOADED',
|
||
loadedAt: new Date(),
|
||
});
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_LOADED',
|
||
inventoryId: item.id,
|
||
warehouseId: item.warehouseId,
|
||
description: 'Auto-loaded (PAID booking)',
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
result.loadedCount += 1;
|
||
result.results.push({ inventoryId: item.id, status: 'LOADED' });
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
|
||
|
||
/**
|
||
* Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route
|
||
* (origin/destination yard countries). Pass a direction to filter to one; omit it to return
|
||
* all import + export bookings in a single call (DOMESTIC routes are excluded either way).
|
||
*/
|
||
async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
|
||
const rows: Array<
|
||
EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null }
|
||
> = await this.dataSource.query(
|
||
`SELECT b.id,
|
||
b.reference AS "reference",
|
||
b.company_id AS "customerId",
|
||
company.name AS "customer",
|
||
company.tin AS "customerTin",
|
||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
|
||
bcu.seal_numbers AS "sealNumbers",
|
||
bc.container_quantity AS "containerQuantity",
|
||
bc.container_packaging_type AS "containerPackagingType",
|
||
-- service_types.includes_last_mile/first_mile are NOT read here: every
|
||
-- service type ships with both true, so OR-ing them in made this always
|
||
-- true regardless of the customer's actual self-haul/EDR-haul choice.
|
||
-- The address is the only per-booking record of that choice.
|
||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||
oy.code AS "origin",
|
||
dy.code AS "destination",
|
||
oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
b.freight_type AS "freightType",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||
b.cargo_total_weight_vgm AS "weight",
|
||
b.payment_status AS "paymentStatus",
|
||
b.status AS "status",
|
||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||
fm.id AS "firstMileRequestId",
|
||
fm.status AS "firstMileStatus",
|
||
fm.vehicle_id AS "firstMileVehicleId",
|
||
v.plate_number AS "firstMileTruckPlateNumber",
|
||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||
COALESCE(
|
||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||
v.assigned_driver_name
|
||
) AS "firstMileDriverName",
|
||
driver.phone_number AS "firstMileDriverPhone",
|
||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||
v.vehicle_type AS "firstMileTruckType",
|
||
-- Multi-truck self-haul writes plates/drivers to
|
||
-- customer_truck_assignments and leaves the booking columns null,
|
||
-- so read the assignments first and keep the legacy column as the
|
||
-- fallback for single-truck bookings written before that table.
|
||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||
b.customer_truck_type AS "customerTruckType",
|
||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
${primaryContactUserJoin('company')}
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||
SUM(booking_container.quantity)::int AS container_quantity,
|
||
CASE
|
||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||
ELSE 'OTHER_CONTAINER'
|
||
END AS container_packaging_type
|
||
FROM freight.booking_container booking_container
|
||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||
) bc ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
|
||
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
|
||
FROM freight.booking_container_units unit
|
||
JOIN freight.booking_container line
|
||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||
WHERE line.booking_id = b.id AND unit.deleted_at IS NULL
|
||
) bcu ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||
FROM freight.first_mile first_mile
|
||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||
ORDER BY first_mile.created_at DESC
|
||
LIMIT 1
|
||
) fm ON true
|
||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||
WHERE b.deleted_at IS NULL
|
||
AND b.payment_status = 'PAID'
|
||
AND inv.id IS NULL
|
||
-- Direct truck-to-train cargo never comes to the warehouse, so never
|
||
-- offer it for receipt.
|
||
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
|
||
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||
);
|
||
|
||
// Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection.
|
||
return rows
|
||
.map((r) => ({
|
||
...r,
|
||
direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }),
|
||
}))
|
||
.filter((r) =>
|
||
direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT',
|
||
);
|
||
}
|
||
|
||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||
/** Sent after the transaction commits so the gateway never blocks the receive. */
|
||
const pendingNotifications: Array<{
|
||
owner: {
|
||
phone?: string | null;
|
||
ownerName?: string | null;
|
||
bookingReference?: string | null;
|
||
grnNumber: string;
|
||
direction?: string | null;
|
||
warehouseId?: string | null;
|
||
};
|
||
booking: {
|
||
companyId?: string | null;
|
||
reference?: string | null;
|
||
hasFirstMile?: boolean;
|
||
hasLastMile?: boolean;
|
||
customerTruckAssignedAt?: string | null;
|
||
};
|
||
bookingId: string;
|
||
}> = [];
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
||
warehouseId: dto.warehouseId,
|
||
yardId: dto.yardId,
|
||
zoneId: dto.zoneId,
|
||
});
|
||
// The receive location is whatever the operator selected above — never a
|
||
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
||
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
|
||
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
||
.filter(Boolean)
|
||
.join(' / ');
|
||
}
|
||
|
||
for (const bookingId of dto.bookingIds) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
||
};
|
||
|
||
const [booking] = await manager.query(
|
||
`SELECT b.reference AS "reference",
|
||
b.payment_status AS "paymentStatus",
|
||
b.freight_type AS "freightType",
|
||
b.cargo_total_weight_vgm AS "weight",
|
||
company.name AS "customer",
|
||
company.tin AS "customerTin",
|
||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||
bc.container_numbers AS "containerNumber",
|
||
bc.container_quantity AS "containerQuantity",
|
||
bc.container_packaging_type AS "containerPackagingType",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||
-- No service_types OR here either — see eligibleBookings above.
|
||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||
fm.id AS "firstMileRequestId",
|
||
fm.status AS "firstMileStatus",
|
||
v.plate_number AS "firstMileTruckPlateNumber",
|
||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||
COALESCE(
|
||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||
v.assigned_driver_name
|
||
) AS "firstMileDriverName",
|
||
driver.phone_number AS "firstMileDriverPhone",
|
||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||
v.vehicle_type AS "firstMileTruckType",
|
||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||
b.customer_truck_type AS "customerTruckType",
|
||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||
b.company_id AS "companyId",
|
||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
${primaryContactUserJoin('company')}
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||
SUM(booking_container.quantity)::int AS container_quantity,
|
||
CASE
|
||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||
ELSE 'OTHER_CONTAINER'
|
||
END AS container_packaging_type
|
||
FROM freight.booking_container booking_container
|
||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||
) bc ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||
FROM freight.first_mile first_mile
|
||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||
ORDER BY first_mile.created_at DESC
|
||
LIMIT 1
|
||
) fm ON true
|
||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
if (!booking) { skip('Booking not found'); continue; }
|
||
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
||
// Direction is derived from the route (yard countries), not the stored field.
|
||
const bookingDirection = deriveTradeDirection(
|
||
{ country: booking.originCountry },
|
||
{ country: booking.destinationCountry },
|
||
);
|
||
if (bookingDirection !== dto.direction) {
|
||
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
||
continue;
|
||
}
|
||
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
|
||
if (!booking.firstMileRequestId) {
|
||
skip('First-mile request not created');
|
||
continue;
|
||
}
|
||
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
|
||
skip('First-mile truck has not arrived');
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
||
if (existing) { skip('Already received'); continue; }
|
||
|
||
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
||
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
||
skip('Container booking has no container quantity');
|
||
continue;
|
||
}
|
||
|
||
const now = new Date();
|
||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
||
const truckEntrance = dto.truckEntrance
|
||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||
: undefined;
|
||
if (dto.direction === 'EXPORT') {
|
||
this.assertTruckEntrance(truckEntrance);
|
||
}
|
||
const receiveNote = this.buildReceiveNote({
|
||
grnNumber,
|
||
direction: dto.direction,
|
||
notes: `Bulk received (${dto.direction})`,
|
||
truckEntrance,
|
||
});
|
||
|
||
// Validate capacity before saving
|
||
const weight = Number(booking.weight) || 0;
|
||
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
|
||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||
|
||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||
manager.getRepository(WarehouseInventory).create({
|
||
warehouseId: dto.warehouseId,
|
||
yardId: dto.yardId,
|
||
zoneId: dto.zoneId,
|
||
bookingId,
|
||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||
weight,
|
||
grnNumber,
|
||
status: 'RECEIVED',
|
||
arrivedAt: now,
|
||
notes: receiveNote,
|
||
}),
|
||
);
|
||
|
||
// Update warehouse/yard/zone capacity counters
|
||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||
|
||
// Receiving the booking flags every container unit as received into the
|
||
// port (self-haul export: the delivering truck's goods are now in) so
|
||
// staff can raise the per-container GRN over what's received.
|
||
await manager.query(
|
||
`UPDATE freight.booking_container_units bcu
|
||
SET received_to_port = true,
|
||
received_at = COALESCE(bcu.received_at, NOW()),
|
||
updated_at = NOW()
|
||
FROM freight.booking_container bc
|
||
WHERE bc.id = bcu.booking_container_id
|
||
AND bc.booking_id = $1
|
||
AND bc.deleted_at IS NULL
|
||
AND bcu.deleted_at IS NULL
|
||
AND bcu.received_to_port = false`,
|
||
[bookingId],
|
||
);
|
||
|
||
// Export self-haul: this receive IS the truck's arrival — see
|
||
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
||
if (dto.direction === 'EXPORT') {
|
||
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
||
}
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_RECEIVED',
|
||
inventoryId: saved.id,
|
||
warehouseId: dto.warehouseId,
|
||
description: truckEntrance?.truckPlateNumber
|
||
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
||
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
||
performedBy: dto.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
|
||
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||
// holds capacity/location locks open for the whole gateway latency.
|
||
pendingNotifications.push({
|
||
owner: {
|
||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||
grnNumber,
|
||
direction: dto.direction,
|
||
warehouseId: dto.warehouseId,
|
||
},
|
||
booking,
|
||
bookingId,
|
||
});
|
||
|
||
result.receivedCount += 1;
|
||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||
}
|
||
});
|
||
|
||
// Fan out after commit, un-awaited: the receive response must not wait on the
|
||
// SMS gateway. Both notifiers swallow their own errors.
|
||
for (const pending of pendingNotifications) {
|
||
void this.notifyOwnerInventoryReceived(pending.owner);
|
||
void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
|
||
if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||
private async exportInventoryByStatus(
|
||
status: WarehouseInventoryStatus,
|
||
): Promise<ReadyToLoadRow[]> {
|
||
const rows: Array<
|
||
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
||
> = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.booking_id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
b.company_id AS "customerId",
|
||
company.name AS "customerName",
|
||
ct.container_number AS "containerNumber",
|
||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||
inv.weight AS "weight",
|
||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||
oy.code AS "origin",
|
||
dy.code AS "destination",
|
||
oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
inv.inspection_status AS "inspectionStatus",
|
||
inv.status
|
||
FROM freight.warehouse_inventory inv
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status = $1
|
||
ORDER BY inv.created_at DESC`,
|
||
[status],
|
||
);
|
||
|
||
return rows
|
||
.filter((r) => {
|
||
const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry });
|
||
return dir === 'EXPORT';
|
||
})
|
||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||
}
|
||
|
||
/**
|
||
* EXPORT inventory waiting to be loaded (READY_FOR_LOADING). Inspection state
|
||
* rides along on each row for the UI to show, but does not filter the queue —
|
||
* uninspected cargo must still be loadable.
|
||
*/
|
||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||
return this.exportInventoryByStatus('READY_FOR_LOADING');
|
||
}
|
||
|
||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||
async receivedExport(): Promise<ReadyToLoadRow[]> {
|
||
return this.exportInventoryByStatus('RECEIVED');
|
||
}
|
||
|
||
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
||
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
||
return this.exportInventoryByStatus('LOADED');
|
||
}
|
||
|
||
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
|
||
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
|
||
// the arrived containers/cargoes assigned to it, and load the ready ones onto
|
||
// their already-allocated wagons. Reuses the single-item load() machinery.
|
||
|
||
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||
/**
|
||
* Export flow this queue serves: booked -> paid -> received at the warehouse
|
||
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
|
||
* booking. Which bookings ride a train comes from the shared CTE.
|
||
*/
|
||
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
|
||
|
||
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||
const rows: Array<
|
||
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||
> = await this.dataSource.query(
|
||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||
SELECT ts.id AS "scheduleId",
|
||
ts.train_number AS "trainNumber",
|
||
oy.code AS "origin",
|
||
dy.code AS "destination",
|
||
oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
ts.status AS "status",
|
||
ts.scheduled_departure_date AS "departureTime",
|
||
(SELECT count(*) FROM sched_bookings sb
|
||
JOIN freight.warehouse_inventory inv
|
||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||
WHERE sb.schedule_id = ts.id
|
||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
|
||
(SELECT count(*) FROM sched_bookings sb
|
||
JOIN freight.warehouse_inventory inv
|
||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||
WHERE sb.schedule_id = ts.id
|
||
AND inv.status = 'LOADED') AS "loadedCount"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status = ANY($1)
|
||
AND EXISTS (
|
||
SELECT 1 FROM sched_bookings sb2
|
||
JOIN freight.warehouse_inventory inv2
|
||
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
|
||
WHERE sb2.schedule_id = ts.id
|
||
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||
)
|
||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||
[['DRAFT', 'SCHEDULED']],
|
||
);
|
||
|
||
return rows
|
||
.filter(
|
||
(r) =>
|
||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
|
||
)
|
||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
|
||
...rest,
|
||
readyCount: Number(rest.readyCount) || 0,
|
||
loadedCount: Number(rest.loadedCount) || 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Container/cargo inventory items assigned to a train, with the wagon each is
|
||
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
|
||
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
|
||
*/
|
||
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
||
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||
SELECT inv.id AS "id",
|
||
inv.booking_id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
company.name AS "customerName",
|
||
ct.container_number AS "containerNumber",
|
||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||
inv.weight AS "weight",
|
||
-- receive() stamps the GRN onto the row and mirrors it into the
|
||
-- note; prefer the column and fall back for legacy/seeded rows.
|
||
COALESCE(
|
||
inv.grn_number,
|
||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||
) AS "grnNumber",
|
||
inv.inspection_status AS "inspectionStatus",
|
||
inv.status AS "status",
|
||
wl.wagon_id AS "wagonId",
|
||
wl.wagon_number AS "wagonNumber",
|
||
wl.sequence_no AS "sequenceNo"
|
||
FROM sched_bookings sb
|
||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_set_wagons tsw
|
||
ON tsw.id = wba.train_set_wagon_id
|
||
AND tsw.train_set_id = ts.train_set_id
|
||
AND tsw.deleted_at IS NULL
|
||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||
LIMIT 1
|
||
) wl ON true
|
||
WHERE sb.schedule_id = $1
|
||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||
[scheduleId],
|
||
);
|
||
|
||
return rows.map((r) => ({
|
||
...r,
|
||
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
|
||
// The row only exists once the goods were received, so requiring a GRN and
|
||
// an allocated wagon completes the chain.
|
||
loadable:
|
||
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Load the selected inventory items onto their allocated wagons for the given
|
||
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
|
||
* an allocated wagon; others are skipped with a reason. When every inventory
|
||
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
|
||
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
|
||
*/
|
||
async loadItemsOntoTrain(
|
||
scheduleId: string,
|
||
inventoryIds: string[],
|
||
performedBy?: string,
|
||
): Promise<TrainLoadResult> {
|
||
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||
const [schedule]: Array<{
|
||
trainNumber: string | null;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
departure: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT ts.train_number AS "trainNumber",
|
||
COALESCE(oy.label, oy.code) AS "origin",
|
||
COALESCE(dy.label, dy.code) AS "destination",
|
||
ts.scheduled_departure_date AS "departure"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||
[scheduleId],
|
||
);
|
||
const trainNote = schedule
|
||
? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` +
|
||
(schedule.origin || schedule.destination
|
||
? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})`
|
||
: '') +
|
||
(schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '')
|
||
: undefined;
|
||
const items = await this.trainLoadableItems(scheduleId);
|
||
const byId = new Map(items.map((i) => [i.id, i]));
|
||
const affectedBookingIds = new Set<string>();
|
||
|
||
for (const inventoryId of inventoryIds) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||
};
|
||
const item = byId.get(inventoryId);
|
||
if (!item) { skip('Not assigned to this train'); continue; }
|
||
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
||
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
||
// Export: the GRN is raised when the goods arrive at the warehouse, and
|
||
// nothing rides a train without one.
|
||
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
|
||
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||
|
||
try {
|
||
await this.load(inventoryId, {
|
||
wagonId: item.wagonId,
|
||
loadedBy: performedBy,
|
||
trainScheduleId: scheduleId,
|
||
notes: trainNote,
|
||
});
|
||
result.loadedCount += 1;
|
||
result.results.push({ inventoryId, status: 'LOADED' });
|
||
if (item.bookingId) affectedBookingIds.add(item.bookingId);
|
||
} catch (error) {
|
||
skip(error instanceof Error ? error.message : 'Load failed');
|
||
}
|
||
}
|
||
|
||
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
|
||
for (const bookingId of affectedBookingIds) {
|
||
await this.dataSource.query(
|
||
`UPDATE freight.train_schedule_bookings tsb
|
||
SET loading_status = 'LOADED', updated_at = NOW()
|
||
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM freight.warehouse_inventory inv
|
||
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
|
||
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
|
||
)`,
|
||
[scheduleId, bookingId],
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
|
||
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
|
||
const rows: Array<
|
||
ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
|
||
> = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.booking_id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
b.company_id AS "customerId",
|
||
company.name AS "customerName",
|
||
COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime",
|
||
(SELECT c.container_number FROM freight.containers c
|
||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||
inv.weight AS "weight",
|
||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||
ts.train_number AS "trainSchedule",
|
||
inv.inspection_status AS "inspectionStatus",
|
||
-- No service_types OR here either — see eligibleBookings above.
|
||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
|
||
-- Multi-truck self-haul writes plates/drivers to
|
||
-- customer_truck_assignments and leaves the booking columns null,
|
||
-- so read the assignments first and keep the legacy column as the
|
||
-- fallback for single-truck bookings written before that table.
|
||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||
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",
|
||
b.double_handling AS "doubleHandling",
|
||
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
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||
WHERE inv.deleted_at IS NULL
|
||
AND inv.status = ANY($1)
|
||
ORDER BY inv.created_at DESC`,
|
||
[statuses],
|
||
);
|
||
|
||
return rows
|
||
.filter(
|
||
(r) =>
|
||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
|
||
)
|
||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||
}
|
||
|
||
/**
|
||
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
|
||
* states), with the columns the inspection screen needs. Read-only.
|
||
*/
|
||
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
|
||
return this.importQueueByStatuses([
|
||
'UNLOADED',
|
||
'DESTINATION_INSPECTION',
|
||
'UNDER_INSPECTION',
|
||
'ARRIVED_AT_WAREHOUSE',
|
||
'STORED',
|
||
'READY_FOR_PICKUP',
|
||
'DISPATCHED',
|
||
'DELIVERED',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
|
||
* awaiting customer pickup / last mile / store / dispatch. Read-only.
|
||
*/
|
||
importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
|
||
return this.importQueueByStatuses(['READY_FOR_PICKUP']);
|
||
}
|
||
|
||
/**
|
||
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
|
||
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
|
||
* LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT.
|
||
*/
|
||
async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise<BulkDispatchResult> {
|
||
const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] };
|
||
|
||
for (const inventoryId of inventoryIds) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||
};
|
||
|
||
const item = await this.inventoryRepository.findById(inventoryId);
|
||
if (!item) { skip('Inventory not found'); continue; }
|
||
if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; }
|
||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
|
||
|
||
try {
|
||
await this.dispatch(inventoryId, performedBy);
|
||
result.dispatchedCount += 1;
|
||
result.results.push({ inventoryId, status: 'DISPATCHED' });
|
||
} catch (error) {
|
||
skip(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/** Booking statuses that must never be unloaded into warehouse inventory. */
|
||
private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED'];
|
||
private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [
|
||
'LOADED',
|
||
'DISPATCHED',
|
||
'IN_TRANSIT',
|
||
'ARRIVED_AT_DJIBOUTI',
|
||
'ARRIVED_AT_PORT',
|
||
'ARRIVED_AT_DESTINATION',
|
||
];
|
||
|
||
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
|
||
const normalized = (value ?? '').toUpperCase();
|
||
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
|
||
normalized.includes(token),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A booking alighted from a train at ITS OWN destination yard — emitted by
|
||
* BookingJourneyService.unloadBooking() for every direction, whether that
|
||
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
|
||
* yard (manual per-booking unload). That per-booking flow only ever flips
|
||
* booking.status; it never creates a warehouse_inventory row, which used to
|
||
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
|
||
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
|
||
* (nothing else was watching for a mid-corridor arrival). This creates that
|
||
* row the moment the cargo is physically off the train.
|
||
*
|
||
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
|
||
* created at the ORIGIN warehouse receive, before the cargo ever boards —
|
||
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
|
||
* above. Creating a second row here would duplicate that receipt.
|
||
*
|
||
* Idempotent — a booking already unloaded via this listener, a retried
|
||
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
|
||
*/
|
||
@OnEvent('booking.unloadedAtYard')
|
||
async handleBookingUnloadedAtYard(payload: {
|
||
bookingId: string;
|
||
tradeDirection: string | null;
|
||
}): Promise<void> {
|
||
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
|
||
try {
|
||
const existing = (
|
||
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
|
||
)[0];
|
||
if (existing) return;
|
||
|
||
const [booking]: Array<{
|
||
weight: string | null;
|
||
freightType: string | null;
|
||
cargoTypeCode: string | null;
|
||
customer: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT COALESCE(
|
||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||
(SELECT SUM(bcu.vgm_tons)
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc2
|
||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||
) AS weight,
|
||
b.freight_type AS "freightType",
|
||
cgt.code AS "cargoTypeCode",
|
||
company.name AS customer
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||
[payload.bookingId],
|
||
);
|
||
if (!booking) return;
|
||
|
||
const allocated = await this.allocation.resolveLocation({
|
||
freightType: booking.freightType,
|
||
tradeDirection: payload.tradeDirection,
|
||
cargoTypeCode: booking.cargoTypeCode,
|
||
});
|
||
const location = allocated ?? (await this.pickDefaultLocation());
|
||
if (!location) {
|
||
this.logger.warn(
|
||
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const now = new Date();
|
||
const saved = await this.inventoryRepository.create({
|
||
warehouseId: location.warehouseId,
|
||
yardId: location.yardId,
|
||
zoneId: location.zoneId,
|
||
bookingId: payload.bookingId,
|
||
quantity: 1,
|
||
weight: Number(booking.weight) || 0,
|
||
status: 'UNLOADED',
|
||
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
|
||
arrivedAt: now,
|
||
unloadedAt: now,
|
||
notes:
|
||
(allocated as { rule?: { name: string } | null } | null)?.rule
|
||
? `Unloaded → ${(allocated as { path?: string | null }).path}`
|
||
: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||
});
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_UNLOADED',
|
||
inventoryId: saved.id,
|
||
warehouseId: saved.warehouseId,
|
||
description: 'Unloaded from arrived train (checkpoint auto-unload)',
|
||
});
|
||
if (Number(saved.weight) > 0) {
|
||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
|
||
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||
* items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue.
|
||
*/
|
||
async autoUnloadArrivedBookings(
|
||
scheduleId: string,
|
||
performedBy?: string,
|
||
warehouseId?: string,
|
||
assignments: BookingUnloadLocation[] = [],
|
||
): Promise<AutoUnloadArrivedResult> {
|
||
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||
|
||
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
|
||
const [schedule] = await this.dataSource.query(
|
||
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
|
||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[scheduleId],
|
||
);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== 'ARRIVED') {
|
||
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
|
||
}
|
||
const direction = deriveTradeDirection(
|
||
{ country: schedule.originCountry },
|
||
{ country: schedule.destinationCountry },
|
||
);
|
||
if (direction !== 'IMPORT') {
|
||
throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`);
|
||
}
|
||
|
||
// 2. Assigned bookings on this train.
|
||
const bookings: {
|
||
id: string;
|
||
status: string;
|
||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||
customer: string | null;
|
||
weight: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
cargoTypeCode: string | null;
|
||
}[] = await this.dataSource.query(
|
||
// Only bookings whose destination IS this train's final yard unload into
|
||
// this (final-destination) warehouse. A mid-corridor import that alighted
|
||
// at an intermediate yard was already unloaded there by the checkpoint
|
||
// auto-unload; without this filter it would be mis-located into the final
|
||
// yard's inventory too.
|
||
`SELECT b.id, b.status,
|
||
-- Same fallback as autoUnloadArrived: never land a 0 t receipt when
|
||
-- the booking's containers carry a VGM.
|
||
COALESCE(
|
||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||
(SELECT SUM(bcu.vgm_tons)
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc2
|
||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||
) AS weight,
|
||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||
cgt.code AS "cargoTypeCode",
|
||
company.name AS customer
|
||
FROM freight.train_schedule_bookings tsb
|
||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||
AND b.destination_yard_id = $2`,
|
||
[scheduleId, schedule.destinationStationId],
|
||
);
|
||
|
||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||
if (warehouseId && !requestedLocation) {
|
||
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
|
||
}
|
||
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
|
||
const assignmentByBooking = new Map(
|
||
assignments.map((assignment) => [
|
||
assignment.bookingId,
|
||
{
|
||
warehouseId: assignment.warehouseId,
|
||
yardId: assignment.yardId,
|
||
zoneId: assignment.zoneId,
|
||
} satisfies LocationRef,
|
||
]),
|
||
);
|
||
const now = new Date();
|
||
|
||
for (const booking of bookings) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason });
|
||
};
|
||
const fail = (reason: string) => {
|
||
result.failedCount += 1;
|
||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason });
|
||
};
|
||
|
||
if (this.IMPORT_UNLOAD_BLOCKED_STATUSES.includes(booking.status)) {
|
||
skip(`Booking status ${booking.status} cannot be unloaded`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
|
||
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
|
||
const unloadLocation = assignedLocation ?? requestedLocation;
|
||
|
||
// Already unloaded or further along — leave it (do not regress the lifecycle).
|
||
if (existing && existing.status !== 'RECEIVED') {
|
||
skip(`Inventory already ${existing.status}`);
|
||
continue;
|
||
}
|
||
|
||
if (existing) {
|
||
await this.inventoryRepository.update(existing.id, {
|
||
...(unloadLocation
|
||
? {
|
||
warehouseId: unloadLocation.warehouseId,
|
||
yardId: unloadLocation.yardId,
|
||
zoneId: unloadLocation.zoneId,
|
||
}
|
||
: {}),
|
||
// Record the received weight on a row that never carried one — the
|
||
// GRN prints this, and an existing non-zero weight is left alone.
|
||
...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0)
|
||
? {}
|
||
: { weight: Number(booking.weight) }),
|
||
status: 'UNLOADED',
|
||
unloadedAt: now,
|
||
arrivedAt: existing.arrivedAt ?? now,
|
||
// Import GRN is issued automatically at train unload.
|
||
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }),
|
||
});
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_UNLOADED',
|
||
inventoryId: existing.id,
|
||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||
description: 'Unloaded from arrived import train',
|
||
performedBy,
|
||
});
|
||
// Capacity follows the recorded weight: deliver() decrements by the
|
||
// item's weight, so a weight written here must be counted here too.
|
||
const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0);
|
||
if (addedWeight > 0) {
|
||
await this.applyCapacityDelta(
|
||
this.dataSource.manager,
|
||
{
|
||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||
yardId: unloadLocation?.yardId ?? existing.yardId,
|
||
zoneId: unloadLocation?.zoneId ?? existing.zoneId,
|
||
},
|
||
addedWeight,
|
||
0,
|
||
0,
|
||
);
|
||
}
|
||
result.unloadedCount += 1;
|
||
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
|
||
continue;
|
||
}
|
||
|
||
// No inventory yet — create it at the allocated (or default) location, in UNLOADED state.
|
||
const allocated = await this.allocation.resolveLocation({
|
||
freightType: booking.freightType,
|
||
tradeDirection: booking.tradeDirection,
|
||
cargoTypeCode: booking.cargoTypeCode,
|
||
});
|
||
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
|
||
if (!location) {
|
||
fail('No warehouse/yard/zone configured');
|
||
continue;
|
||
}
|
||
|
||
const saved = await this.inventoryRepository.create({
|
||
warehouseId: location.warehouseId,
|
||
yardId: location.yardId,
|
||
zoneId: location.zoneId,
|
||
bookingId: booking.id,
|
||
quantity: 1,
|
||
weight: Number(booking.weight) || 0,
|
||
status: 'UNLOADED',
|
||
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
|
||
arrivedAt: now,
|
||
unloadedAt: now,
|
||
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
|
||
});
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_UNLOADED',
|
||
inventoryId: saved.id,
|
||
warehouseId: saved.warehouseId,
|
||
description: 'Unloaded from arrived import train',
|
||
performedBy,
|
||
});
|
||
// New goods physically in the warehouse — count them, or deliver() would
|
||
// later free capacity that was never taken.
|
||
if (Number(saved.weight) > 0) {
|
||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||
}
|
||
result.unloadedCount += 1;
|
||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
|
||
} catch (error) {
|
||
fail(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Unload eligible EXPORT inventory from an arrived Djibouti-side train.
|
||
* This only advances warehouse inventory items assigned to the train and does not write to
|
||
* train schedules, wagon assignment, rescheduling, or booking payment state.
|
||
*/
|
||
async autoUnloadExportAtDjibouti(
|
||
scheduleId: string,
|
||
performedBy?: string,
|
||
): Promise<AutoUnloadExportDjiboutiResult> {
|
||
const result: AutoUnloadExportDjiboutiResult = {
|
||
unloadedCount: 0,
|
||
skippedCount: 0,
|
||
failedCount: 0,
|
||
results: [],
|
||
};
|
||
|
||
const [schedule] = await this.dataSource.query(
|
||
`SELECT ts.id,
|
||
ts.status,
|
||
oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
dy.code AS "destinationCode",
|
||
dy.label AS "destinationName"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[scheduleId],
|
||
);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const direction = deriveTradeDirection(
|
||
{ country: schedule.originCountry },
|
||
{ country: schedule.destinationCountry },
|
||
);
|
||
if (direction !== 'EXPORT') {
|
||
throw new BadRequestException(`Train schedule route is ${direction}, not EXPORT`);
|
||
}
|
||
if (!this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||
throw new BadRequestException('Train schedule destination is not Djibouti / Doraleh / DMP / DCT / Nagad');
|
||
}
|
||
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
|
||
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
|
||
}
|
||
const [gatepass] = await this.dataSource.query(
|
||
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
|
||
FROM freight.import_djibouti_operations
|
||
WHERE train_schedule_id = $1 AND deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[scheduleId],
|
||
);
|
||
if (!gatepass?.gatepassSecuredAt) {
|
||
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
|
||
}
|
||
|
||
const items: Array<{
|
||
bookingId: string;
|
||
inventoryId: string | null;
|
||
inventoryStatus: string | null;
|
||
bookingStatus: string | null;
|
||
warehouseId: string | null;
|
||
yardId: string | null;
|
||
zoneId: string | null;
|
||
itemType: 'CONTAINER' | 'CARGO';
|
||
itemId: string | null;
|
||
containerNumber: string | null;
|
||
}> = await this.dataSource.query(
|
||
`WITH assigned AS (
|
||
SELECT b.id AS booking_id,
|
||
b.status AS booking_status,
|
||
inv.id AS inventory_id,
|
||
inv.status AS inventory_status,
|
||
inv.warehouse_id,
|
||
inv.yard_id,
|
||
inv.zone_id
|
||
FROM freight.train_schedule_bookings tsb
|
||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||
)
|
||
SELECT a.booking_id AS "bookingId",
|
||
a.inventory_id AS "inventoryId",
|
||
a.inventory_status AS "inventoryStatus",
|
||
a.booking_status AS "bookingStatus",
|
||
a.warehouse_id AS "warehouseId",
|
||
a.yard_id AS "yardId",
|
||
a.zone_id AS "zoneId",
|
||
'CONTAINER' AS "itemType",
|
||
c.id AS "itemId",
|
||
c.container_number AS "containerNumber"
|
||
FROM assigned a
|
||
JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL
|
||
UNION ALL
|
||
SELECT a.booking_id AS "bookingId",
|
||
a.inventory_id AS "inventoryId",
|
||
a.inventory_status AS "inventoryStatus",
|
||
a.booking_status AS "bookingStatus",
|
||
a.warehouse_id AS "warehouseId",
|
||
a.yard_id AS "yardId",
|
||
a.zone_id AS "zoneId",
|
||
'CARGO' AS "itemType",
|
||
cg.id AS "itemId",
|
||
NULL AS "containerNumber"
|
||
FROM assigned a
|
||
JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
|
||
UNION ALL
|
||
SELECT a.booking_id AS "bookingId",
|
||
a.inventory_id AS "inventoryId",
|
||
a.inventory_status AS "inventoryStatus",
|
||
a.booking_status AS "bookingStatus",
|
||
a.warehouse_id AS "warehouseId",
|
||
a.yard_id AS "yardId",
|
||
a.zone_id AS "zoneId",
|
||
'CARGO' AS "itemType",
|
||
a.inventory_id AS "itemId",
|
||
NULL AS "containerNumber"
|
||
FROM assigned a
|
||
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
|
||
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)`,
|
||
[scheduleId],
|
||
);
|
||
|
||
const seenInventory = new Set<string>();
|
||
const now = new Date();
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
for (const item of items) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({
|
||
bookingId: item.bookingId,
|
||
itemType: item.itemType,
|
||
itemId: item.itemId,
|
||
inventoryId: item.inventoryId ?? undefined,
|
||
containerNumber: item.containerNumber,
|
||
status: 'SKIPPED',
|
||
reason,
|
||
});
|
||
};
|
||
const fail = (reason: string) => {
|
||
result.failedCount += 1;
|
||
result.results.push({
|
||
bookingId: item.bookingId,
|
||
itemType: item.itemType,
|
||
itemId: item.itemId,
|
||
inventoryId: item.inventoryId ?? undefined,
|
||
containerNumber: item.containerNumber,
|
||
status: 'FAILED',
|
||
reason,
|
||
});
|
||
};
|
||
|
||
if (!item.inventoryId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||
skip('No warehouse inventory found for assigned export item');
|
||
continue;
|
||
}
|
||
if (seenInventory.has(item.inventoryId)) {
|
||
result.results.push({
|
||
bookingId: item.bookingId,
|
||
itemType: item.itemType,
|
||
itemId: item.itemId,
|
||
inventoryId: item.inventoryId,
|
||
containerNumber: item.containerNumber,
|
||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||
message: 'Unloaded at Djibouti Port',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const currentStatus = item.inventoryStatus ?? item.bookingStatus;
|
||
if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
|
||
seenInventory.add(item.inventoryId);
|
||
result.unloadedCount += 1;
|
||
result.results.push({
|
||
bookingId: item.bookingId,
|
||
itemType: item.itemType,
|
||
itemId: item.itemId,
|
||
inventoryId: item.inventoryId,
|
||
containerNumber: item.containerNumber,
|
||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||
message: 'Already unloaded at Djibouti Port',
|
||
});
|
||
continue;
|
||
}
|
||
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
|
||
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
await manager.getRepository(WarehouseInventory).update(item.inventoryId, {
|
||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||
unloadedAt: now,
|
||
arrivedAt: now,
|
||
notes: 'Unloaded at Djibouti Port',
|
||
});
|
||
await manager.getRepository(WarehouseInventoryMovement).save(
|
||
manager.getRepository(WarehouseInventoryMovement).create({
|
||
inventoryId: item.inventoryId,
|
||
fromWarehouseId: item.warehouseId,
|
||
fromYardId: item.yardId,
|
||
fromZoneId: item.zoneId,
|
||
toWarehouseId: item.warehouseId,
|
||
toYardId: item.yardId,
|
||
toZoneId: item.zoneId,
|
||
remarks: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT',
|
||
movedBy: performedBy ?? null,
|
||
movedAt: now,
|
||
}),
|
||
);
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_UNLOADED',
|
||
inventoryId: item.inventoryId,
|
||
warehouseId: item.warehouseId,
|
||
description: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT',
|
||
performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
seenInventory.add(item.inventoryId);
|
||
result.unloadedCount += 1;
|
||
result.results.push({
|
||
bookingId: item.bookingId,
|
||
itemType: item.itemType,
|
||
itemId: item.itemId,
|
||
inventoryId: item.inventoryId,
|
||
containerNumber: item.containerNumber,
|
||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||
message: 'Unloaded at Djibouti Port',
|
||
});
|
||
} catch (error) {
|
||
fail(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
});
|
||
|
||
if (result.unloadedCount > 0) {
|
||
// Best-effort: the unload is already committed — a paperwork failure must
|
||
// not fail the response (it did once: items unloaded, request 500'd, and
|
||
// the document only appeared after a manual retry days later). The doc
|
||
// backfills on any retry since already-unloaded items count as unloaded.
|
||
try {
|
||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||
scheduleId,
|
||
direction: 'EXPORT',
|
||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||
handoverFrom: 'EDR',
|
||
handoverTo: 'Djibouti Port Operator',
|
||
portOperatorName: 'Doraleh Multipurpose Port',
|
||
generatedBy: performedBy ?? 'EDR Operations',
|
||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||
});
|
||
if (document.status !== 'ACKNOWLEDGED') {
|
||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||
acknowledgedBy: 'Djibouti Port Operator',
|
||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||
});
|
||
}
|
||
result.interchangeDocument = {
|
||
id: document.id,
|
||
documentNo: document.documentNo,
|
||
status: document.status,
|
||
};
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
|
||
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
|
||
* For damage / weight-loss / images, use the per-item Inspect / Report action instead.
|
||
*/
|
||
async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
|
||
const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
|
||
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
|
||
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
|
||
|
||
for (const inventoryId of dto.inventoryIds) {
|
||
const skip = (reason: string) => {
|
||
result.skippedCount += 1;
|
||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||
};
|
||
|
||
const item = await this.inventoryRepository.findById(inventoryId);
|
||
if (!item) { skip('Inventory not found'); continue; }
|
||
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
||
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
||
|
||
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
||
await this.inspectionService.create(inventoryId, {
|
||
reportType: 'INSPECTION',
|
||
inspectionStatus: 'PASSED',
|
||
remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).',
|
||
inspectedById: dto.inspectedBy,
|
||
});
|
||
|
||
// A passed item advances by trade direction:
|
||
// EXPORT → Ready To Load (READY_FOR_LOADING)
|
||
// IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading.
|
||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||
if (direction === 'EXPORT') {
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||
status: 'READY_FOR_LOADING',
|
||
readyForLoadingAt: new Date(),
|
||
});
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'READY_FOR_LOADING',
|
||
inventoryId,
|
||
warehouseId: item.warehouseId,
|
||
description: 'Inspection passed → ready for loading',
|
||
performedBy: dto.inspectedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
|
||
} else if (direction === 'IMPORT') {
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||
status: 'READY_FOR_PICKUP',
|
||
readyForPickupAt: new Date(),
|
||
});
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'READY_FOR_PICKUP',
|
||
inventoryId,
|
||
warehouseId: item.warehouseId,
|
||
description: 'Destination inspection passed → pickup ready',
|
||
performedBy: dto.inspectedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
await this.acceptLastMileIfRequested(item.bookingId);
|
||
result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' });
|
||
} else {
|
||
result.results.push({ inventoryId, status: 'INSPECTED' });
|
||
}
|
||
result.inspectedCount += 1;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Receive ──────────────────────────────────────────────────────────────
|
||
|
||
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
|
||
if (!bookingId) return;
|
||
const [booking] = await this.dataSource.query(
|
||
`SELECT reference,
|
||
last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||
FROM freight.bookings b
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
// service_types.includes_last_mile is NOT read here — every service type
|
||
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
|
||
// The delivery address is the only per-booking record of that choice.
|
||
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
|
||
if (!booking?.reference || !hasLastMile) return;
|
||
await this.lastMileService.acceptBooking(booking.reference);
|
||
}
|
||
|
||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||
|
||
const id = await this.dataSource.transaction(async (manager) => {
|
||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||
|
||
const bookingSource = dto.bookingId
|
||
? await this.getBookingTruckEntranceSource(manager, dto.bookingId)
|
||
: null;
|
||
if (dto.bookingId && !bookingSource?.reference) {
|
||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||
}
|
||
const quantity = bookingSource
|
||
? Number(bookingSource.containerQuantity) || 1
|
||
: Number(dto.quantity) || 0;
|
||
const weight = bookingSource
|
||
? Number(bookingSource.weight) || 0
|
||
: Number(dto.weight) || 0;
|
||
const volume = Number(dto.volume) || 0;
|
||
const containerCount = dto.containerId ? Math.round(quantity) : 0;
|
||
const truckEntrance = dto.bookingId
|
||
? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
|
||
: dto.truckEntrance;
|
||
this.assertTruckEntrance(truckEntrance);
|
||
|
||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||
|
||
const now = new Date();
|
||
const grnNumber = this.generateGrnNumber(
|
||
bookingDirection ?? 'WH',
|
||
dto.bookingId ?? 'MANUAL',
|
||
now,
|
||
truckEntrance?.ownerName ?? bookingSource?.customer,
|
||
);
|
||
const receiveNote = this.buildReceiveNote({
|
||
grnNumber,
|
||
notes: dto.notes?.trim() || 'Single booking received',
|
||
truckEntrance,
|
||
});
|
||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||
manager.getRepository(WarehouseInventory).create({
|
||
warehouseId: dto.warehouseId,
|
||
yardId: dto.yardId,
|
||
zoneId: dto.zoneId,
|
||
bookingId: dto.bookingId ?? null,
|
||
cargoId: dto.cargoId ?? null,
|
||
containerId: dto.containerId ?? null,
|
||
goodsId: dto.goodsId ?? null,
|
||
quantity,
|
||
weight,
|
||
volume: dto.volume ?? null,
|
||
grnNumber,
|
||
status: 'RECEIVED',
|
||
arrivedAt: now,
|
||
notes: receiveNote,
|
||
}),
|
||
);
|
||
|
||
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
||
|
||
// Per-container receive: flag this container's unit as received into the
|
||
// port so staff can raise the GRN over what's received.
|
||
if (dto.bookingId && dto.containerId) {
|
||
await manager.query(
|
||
`UPDATE freight.booking_container_units bcu
|
||
SET received_to_port = true,
|
||
received_at = COALESCE(bcu.received_at, NOW()),
|
||
updated_at = NOW()
|
||
FROM freight.booking_container bc, freight.containers cont
|
||
WHERE bc.id = bcu.booking_container_id
|
||
AND bc.booking_id = $1
|
||
AND bc.deleted_at IS NULL
|
||
AND cont.id = $2
|
||
AND cont.container_number = bcu.container_number
|
||
AND bcu.deleted_at IS NULL
|
||
AND bcu.received_to_port = false`,
|
||
[dto.bookingId, dto.containerId],
|
||
);
|
||
}
|
||
|
||
// Export self-haul: this receive IS the truck's arrival — stamp it on
|
||
// its own customer_truck_assignments row (mirror of import's arrival,
|
||
// see markCustomerTruckArrived).
|
||
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||
await this.markCustomerTruckArrived(manager, dto.bookingId, truckEntrance.truckPlateNumber);
|
||
}
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_RECEIVED',
|
||
inventoryId: saved.id,
|
||
warehouseId: dto.warehouseId,
|
||
description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
|
||
performedBy: dto.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
|
||
await this.notifyOwnerInventoryReceived({
|
||
phone: truckEntrance.customerPhone,
|
||
ownerName: truckEntrance.ownerName,
|
||
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
|
||
grnNumber,
|
||
direction: bookingDirection,
|
||
warehouseId: dto.warehouseId,
|
||
});
|
||
|
||
return saved.id;
|
||
});
|
||
|
||
if (dto.bookingId && bookingDirection === 'EXPORT') {
|
||
void this.notifyCarriageAcceptanceReady(dto.bookingId);
|
||
}
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
|
||
const movedId = await this.dataSource.transaction(async (manager) => {
|
||
const item = await manager.getRepository(WarehouseInventory).findOne({
|
||
where: { id },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!item) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
|
||
if (
|
||
item.warehouseId === dto.warehouseId &&
|
||
item.yardId === dto.yardId &&
|
||
item.zoneId === dto.zoneId
|
||
) {
|
||
throw new BadRequestException('Destination location is the same as current location');
|
||
}
|
||
|
||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||
const weight = Number(item.weight) || 0;
|
||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||
|
||
if (item.warehouseId !== dto.warehouseId) {
|
||
this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount);
|
||
}
|
||
if (item.yardId !== dto.yardId) {
|
||
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
|
||
}
|
||
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
|
||
|
||
await this.applyCapacityDelta(
|
||
manager,
|
||
{
|
||
warehouseId: item.warehouseId,
|
||
yardId: item.yardId,
|
||
zoneId: item.zoneId,
|
||
},
|
||
-weight,
|
||
-(Number(item.volume) || 0),
|
||
-containerCount,
|
||
);
|
||
|
||
await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount);
|
||
|
||
item.warehouseId = dto.warehouseId;
|
||
item.yardId = dto.yardId;
|
||
item.zoneId = dto.zoneId;
|
||
if (dto.remarks?.trim()) {
|
||
const existingNotes = item.notes?.trim();
|
||
item.notes = existingNotes
|
||
? `${existingNotes}\nMove: ${dto.remarks.trim()}`
|
||
: `Move: ${dto.remarks.trim()}`;
|
||
}
|
||
|
||
const saved = await manager.getRepository(WarehouseInventory).save(item);
|
||
return saved.id;
|
||
});
|
||
|
||
return this.findById(movedId);
|
||
}
|
||
|
||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||
|
||
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 = 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');
|
||
}
|
||
|
||
const weight = Number(item.weight) || 0;
|
||
const volume = Number(item.volume) || 0;
|
||
const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const locked = await manager.getRepository(WarehouseInventory).findOne({
|
||
where: { id },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!locked) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
this.assertTransition(locked.status, 'STORED');
|
||
|
||
if (
|
||
locked.warehouseId !== location.warehouseId ||
|
||
locked.yardId !== location.yardId ||
|
||
locked.zoneId !== location.zoneId
|
||
) {
|
||
const { warehouse, yard, zone } = await this.validateLocation(manager, location);
|
||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||
|
||
await this.applyCapacityDelta(
|
||
manager,
|
||
{
|
||
warehouseId: locked.warehouseId,
|
||
yardId: locked.yardId,
|
||
zoneId: locked.zoneId,
|
||
},
|
||
-weight,
|
||
-volume,
|
||
-containerCount,
|
||
);
|
||
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, storedReason),
|
||
});
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_STORED',
|
||
inventoryId: id,
|
||
warehouseId: location.warehouseId,
|
||
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||
performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
|
||
const item = await this.findById(dto.inventoryId);
|
||
|
||
if (item.status !== 'STORED') {
|
||
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
|
||
}
|
||
|
||
const status = await this.getBookingStatus(dto.bookingId);
|
||
if (!status) {
|
||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||
}
|
||
if (status !== 'PAID') {
|
||
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
|
||
status: 'RESERVED',
|
||
bookingId: dto.bookingId,
|
||
reservedAt: new Date(),
|
||
});
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_RESERVED',
|
||
inventoryId: dto.inventoryId,
|
||
warehouseId: item.warehouseId,
|
||
description: `Reserved for booking ${dto.bookingId}`,
|
||
performedBy: dto.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
return this.findById(dto.inventoryId);
|
||
}
|
||
|
||
async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||
const item = await this.findById(id);
|
||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||
}
|
||
// Inspection is tracked, not enforced — uninspected cargo may still be
|
||
// marked ready and loaded so a train is never held for paperwork.
|
||
return this.transition(id, 'READY_FOR_LOADING', {
|
||
timestampField: 'readyForLoadingAt',
|
||
activityType: 'READY_FOR_LOADING',
|
||
description: 'Inventory ready for loading',
|
||
performedBy,
|
||
preloaded: item,
|
||
});
|
||
}
|
||
|
||
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
|
||
|
||
/** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */
|
||
async readyForPickup(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||
const item = await this.findById(id);
|
||
|
||
if (item.inspectionStatus !== 'PASSED') {
|
||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup');
|
||
}
|
||
|
||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||
if (direction !== 'IMPORT') {
|
||
throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup');
|
||
}
|
||
|
||
return this.transition(id, 'READY_FOR_PICKUP', {
|
||
timestampField: 'readyForPickupAt',
|
||
activityType: 'READY_FOR_PICKUP',
|
||
description: 'Inventory ready for customer pickup',
|
||
performedBy,
|
||
preloaded: item,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
// service_types.includes_last_mile is NOT read here — every service type
|
||
// ships with it true, which made the second disjunct below unreachable and
|
||
// this method effectively return true only from an assigned truck.
|
||
const [row]: Array<{ ok: number }> = await runner.query(
|
||
`SELECT 1 AS ok
|
||
FROM freight.bookings b
|
||
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)`,
|
||
[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);
|
||
if (item.status !== 'READY_FOR_PICKUP') {
|
||
throw new BadRequestException(
|
||
`Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`,
|
||
);
|
||
}
|
||
|
||
// 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) {
|
||
// 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 =
|
||
(await this.handover.isFullySigned(item.bookingId)) ||
|
||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
|
||
if (usesCustomerTruck && !handoverSigned) {
|
||
throw new BadRequestException(
|
||
'Customer must sign the handover before the exit paper can be generated',
|
||
);
|
||
}
|
||
|
||
// A truck leaves with the containers ASSIGNED to it — never another
|
||
// truck's. Enforced whenever the truck has an assigned load on file
|
||
// (customer self-haul or EDR last-mile).
|
||
if (dto.containerNumber && dto.truckPlateNumber?.trim()) {
|
||
const selectedNumbers = dto.containerNumber
|
||
.split(/[,;\n]+/)
|
||
.map((n) => n.trim().toUpperCase())
|
||
.filter(Boolean);
|
||
const assigned = await this.truckAssignedContainers(
|
||
item.bookingId,
|
||
dto.truckPlateNumber.trim(),
|
||
);
|
||
if (assigned.length && selectedNumbers.length) {
|
||
const foreign = selectedNumbers.filter((n) => !assigned.includes(n));
|
||
if (foreign.length) {
|
||
throw new BadRequestException(
|
||
`Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` +
|
||
`not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||
// total VGM cargo weight of the containers selected as loaded on it.
|
||
// 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())
|
||
.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
|
||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||
: item.releaseDate ?? null;
|
||
// One reference per item — the first truck's arrival mints it, later trucks
|
||
// (arrival or exit) reuse it so all exit papers share the release order.
|
||
const reference =
|
||
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||
const exitInspectionDto = {
|
||
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
|
||
};
|
||
// Weighbridge skipped on exit: the recorded net still comes from what the
|
||
// truck is holding — the summed cargo weight of its selected containers.
|
||
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
|
||
const selected = (exitInspectionDto.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 heldTons = Number(
|
||
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
|
||
);
|
||
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
|
||
}
|
||
}
|
||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||
|
||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||
// never does.
|
||
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||
const netTons =
|
||
grossTons != null && tareTons != null
|
||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||
: (exitInspectionDto.netWeight ?? null);
|
||
|
||
// The weight to record on the inventory when this truck leaves: prefer the
|
||
// item's own container cargo weight (a truck may carry other items too);
|
||
// fall back to the truck's recorded net. Fills an empty weight only.
|
||
let recordedItemTons: number | null = null;
|
||
if (isTruckLeaving && netTons != null) {
|
||
recordedItemTons = netTons;
|
||
if (item.containerId && item.bookingId) {
|
||
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
|
||
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
|
||
[item.containerId],
|
||
);
|
||
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
|
||
if (ownNumber) {
|
||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
|
||
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
|
||
}
|
||
}
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(id, {
|
||
releaseDate,
|
||
releaseOrderReference: reference,
|
||
notes: this.replaceExitInspectionNote(
|
||
item.notes,
|
||
exitInspectionNote,
|
||
exitInspectionDto.truckPlateNumber,
|
||
),
|
||
});
|
||
// Even an unweighed truck records the inventory weight it is holding —
|
||
// without this the handover/exit papers print "0 t" for skipped weighings.
|
||
if (recordedItemTons != null) {
|
||
await manager.query(
|
||
`UPDATE freight.warehouse_inventory
|
||
SET weight = $2, updated_at = NOW()
|
||
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
|
||
[id, recordedItemTons],
|
||
);
|
||
}
|
||
if (!isTruckLeaving && item.bookingId) {
|
||
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||
// container as arrived (matched via the physical container number).
|
||
if (item.containerId) {
|
||
await manager.query(
|
||
`UPDATE freight.customer_truck_assignments a
|
||
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
|
||
FROM freight.customer_truck_containers c
|
||
JOIN freight.containers cont ON cont.container_number = c.container_number
|
||
WHERE c.assignment_id = a.id
|
||
AND c.deleted_at IS NULL
|
||
AND c.booking_id = $1
|
||
AND cont.id = $2
|
||
AND a.arrived_at IS NULL
|
||
AND a.deleted_at IS NULL`,
|
||
[item.bookingId, item.containerId],
|
||
);
|
||
// NB: import arrival changes nothing on the goods — received_to_port is
|
||
// an EXPORT concept (set when a truck delivers into the port). Import
|
||
// load + weight are captured on truck departure, not arrival.
|
||
}
|
||
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||
// container so it works for bulk too (bulk trucks carry no container).
|
||
if (dto.truckPlateNumber?.trim()) {
|
||
await manager.query(
|
||
`UPDATE freight.last_mile_vehicle_assignments va
|
||
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||
FROM freight.last_mile lm, freight.vehicles v
|
||
WHERE va.last_mile_id = lm.id
|
||
AND lm.booking_id = $1
|
||
AND lm.deleted_at IS NULL
|
||
AND v.id = va.vehicle_id
|
||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||
AND va.arrived_at IS NULL
|
||
AND va.deleted_at IS NULL`,
|
||
[item.bookingId, dto.truckPlateNumber.trim()],
|
||
);
|
||
}
|
||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||
// handover is signed ONCE (before the first truck leaves), even though
|
||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||
await manager.query(
|
||
`UPDATE freight.bookings
|
||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||
updated_at = NOW()
|
||
WHERE id = $1
|
||
AND customer_truck_assigned_at IS NOT NULL
|
||
AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
// Self-haul: generate the per-booking handover on first truck arrival
|
||
// (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);
|
||
}
|
||
}
|
||
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||
// 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).
|
||
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()),
|
||
gross_weight_tons = $4,
|
||
net_weight_tons = $5,
|
||
updated_at = NOW()
|
||
FROM freight.last_mile lm, freight.vehicles v
|
||
WHERE va.last_mile_id = lm.id
|
||
AND lm.booking_id = $1
|
||
AND lm.deleted_at IS NULL
|
||
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
|
||
RETURNING va.id`,
|
||
[
|
||
item.bookingId,
|
||
dto.truckPlateNumber.trim(),
|
||
dto.gateOutTime ?? null,
|
||
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
|
||
// plate rather than container so bulk trucks (which carry none) count.
|
||
await manager.query(
|
||
`UPDATE freight.customer_truck_assignments a
|
||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||
arrived_at = COALESCE(a.arrived_at, NOW()),
|
||
gross_weight_kg = $4,
|
||
tare_weight_tons = $5,
|
||
net_weight_tons = $6,
|
||
updated_at = NOW()
|
||
WHERE a.booking_id = $1
|
||
AND UPPER(a.plate_number) = UPPER($2)
|
||
AND a.departed_at IS NULL
|
||
AND a.deleted_at IS NULL`,
|
||
[
|
||
item.bookingId,
|
||
dto.truckPlateNumber.trim(),
|
||
dto.gateOutTime ?? null,
|
||
grossTons,
|
||
tareTons,
|
||
netTons,
|
||
],
|
||
);
|
||
}
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_RELEASED',
|
||
inventoryId: id,
|
||
warehouseId: item.warehouseId,
|
||
description: isTruckLeaving
|
||
? reference
|
||
? `Exit paper ${reference} generated`
|
||
: 'Exit paper generated'
|
||
: reference
|
||
? `Truck arrival ${reference} registered`
|
||
: 'Truck arrival registered',
|
||
performedBy: dto.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
// Tell the customer their truck has left — one hook covers BOTH self-haul and
|
||
// EDR last-mile, since release() is the single exit path for either. Outside
|
||
// 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
|
||
* provider or contact must not break the exit flow.
|
||
*/
|
||
private async notifyTruckDeparture(
|
||
bookingId: string,
|
||
plateNumber: string | null,
|
||
netTons: number | 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 load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||
await this.inbox.notify({
|
||
recipients: { companyId: booking.companyId },
|
||
audience: NotificationAudience.PORTAL,
|
||
type: NotificationType.BOOKING_STATUS,
|
||
title: 'Truck left the warehouse',
|
||
body,
|
||
link: `/bookings/${bookingId}`,
|
||
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||
});
|
||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* customer_truck_assignments.gross_weight_kg holds TONNES for gate-out
|
||
* recorded exits but real KG for legacy departTruck rows. Exit papers always
|
||
* print tonnes — normalise on read.
|
||
*/
|
||
// ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg);
|
||
// migrate the column to tonnes if it ever bites.
|
||
private grossAsTons(value: number): number {
|
||
return value > 1000 ? Math.round(value) / 1000 : value;
|
||
}
|
||
|
||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const [row] = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.release_date AS "releaseDate",
|
||
inv.release_order_reference AS "releaseOrderReference",
|
||
inv.quantity,
|
||
inv.weight,
|
||
inv.status,
|
||
inv.notes,
|
||
b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
b.status AS "bookingStatus",
|
||
b.freight_type AS "freightType",
|
||
b.trade_direction AS "tradeDirection",
|
||
company.name AS "customerName",
|
||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||
wh.name AS "warehouseName",
|
||
wh.code AS "warehouseCode",
|
||
yard.name AS "yardName",
|
||
yard.code AS "yardCode",
|
||
zone.name AS "zoneName",
|
||
zone.code AS "zoneCode"
|
||
FROM freight.warehouse_inventory inv
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||
LEFT JOIN freight.booking_container booking_container ON (
|
||
booking_container.booking_id = b.id
|
||
AND booking_container.deleted_at IS NULL
|
||
)
|
||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[id],
|
||
);
|
||
if (!row) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
if (!row.releaseDate) {
|
||
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
||
}
|
||
await this.invoices.assertClearanceAllowed(id);
|
||
|
||
// Import self-haul: the exit paper names the pickup truck + all containers it
|
||
// carries, so gate staff can verify the goods leaving on that truck.
|
||
let truck: {
|
||
plateNumber: string;
|
||
driverName: string;
|
||
truckType: string;
|
||
containerNumbers: string;
|
||
truckWeightTons: string | number | null;
|
||
grossWeightKg: string | number | null;
|
||
departedAt: string | null;
|
||
} | null = null;
|
||
// The exit-inspection note written at gate-out names the truck doing THIS
|
||
// exit — resolve by its plate first. The item's own container may not be on
|
||
// the departing truck at all (trucks pick containers freely per trip).
|
||
const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)];
|
||
const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null;
|
||
if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) {
|
||
const [truckRow] = await this.dataSource.query(
|
||
`SELECT a.plate_number AS "plateNumber",
|
||
a.driver_name AS "driverName",
|
||
a.truck_type AS "truckType",
|
||
a.gross_weight_kg AS "grossWeightKg",
|
||
a.departed_at AS "departedAt",
|
||
string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers",
|
||
COALESCE((
|
||
SELECT SUM(bcu.vgm_tons)
|
||
FROM freight.customer_truck_containers cc
|
||
JOIN freight.booking_container_units bcu
|
||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||
JOIN freight.booking_container bc
|
||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||
AND bc.booking_id = a.booking_id
|
||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||
), 0) AS "truckWeightTons"
|
||
FROM freight.customer_truck_assignments a
|
||
LEFT JOIN freight.customer_truck_containers c
|
||
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at
|
||
LIMIT 1`,
|
||
[row.bookingId, exitPlate],
|
||
);
|
||
truck = truckRow ?? null;
|
||
}
|
||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||
const [truckRow] = await this.dataSource.query(
|
||
`SELECT a.plate_number AS "plateNumber",
|
||
a.driver_name AS "driverName",
|
||
a.truck_type AS "truckType",
|
||
a.gross_weight_kg AS "grossWeightKg",
|
||
a.departed_at AS "departedAt",
|
||
string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
|
||
COALESCE((
|
||
SELECT SUM(bcu.vgm_tons)
|
||
FROM freight.customer_truck_containers cc
|
||
JOIN freight.booking_container_units bcu
|
||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||
JOIN freight.booking_container bc
|
||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||
AND bc.booking_id = c.booking_id
|
||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||
), 0) AS "truckWeightTons"
|
||
FROM freight.customer_truck_containers c
|
||
JOIN freight.customer_truck_assignments a
|
||
ON a.id = c.assignment_id AND a.deleted_at IS NULL
|
||
JOIN freight.customer_truck_containers c2
|
||
ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
|
||
WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
|
||
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
|
||
LIMIT 1`,
|
||
[row.bookingId, row.containerNumber],
|
||
);
|
||
truck = truckRow ?? null;
|
||
}
|
||
// Bulk self-haul (no container to match) or an unmatched container: the exit
|
||
// paper is still PER TRUCK — use the latest departed customer truck and its
|
||
// weighed gross, never the booking's declared total.
|
||
if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) {
|
||
const [truckRow] = await this.dataSource.query(
|
||
`SELECT a.plate_number AS "plateNumber",
|
||
a.driver_name AS "driverName",
|
||
a.truck_type AS "truckType",
|
||
a.gross_weight_kg AS "grossWeightKg",
|
||
a.departed_at AS "departedAt",
|
||
NULL AS "containerNumbers",
|
||
a.net_weight_tons AS "truckWeightTons"
|
||
FROM freight.customer_truck_assignments a
|
||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL
|
||
ORDER BY a.departed_at DESC
|
||
LIMIT 1`,
|
||
[row.bookingId],
|
||
);
|
||
truck = truckRow ?? null;
|
||
}
|
||
|
||
const bookingReference = row?.bookingReference || 'N/A';
|
||
const reference =
|
||
row?.releaseOrderReference ||
|
||
(row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A');
|
||
const issuedAt = new Date(row.releaseDate);
|
||
const html = this.buildReleaseDocumentHtml({
|
||
reference,
|
||
issuedAt,
|
||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||
bookingReference,
|
||
bookingStatus: row?.bookingStatus ?? null,
|
||
customerName: row?.customerName ?? null,
|
||
freightType: row?.freightType ?? null,
|
||
tradeDirection: row?.tradeDirection ?? null,
|
||
// Per-truck exit: list every container leaving on THIS truck, not just
|
||
// the inventory item's own container.
|
||
containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null,
|
||
cargoDescription: row?.cargoDescription ?? null,
|
||
quantity: Number(row?.quantity ?? 0),
|
||
weight: Number(row?.weight ?? 0),
|
||
warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null,
|
||
yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
|
||
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
||
inventoryStatus: row?.status ?? null,
|
||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||
truckPlateNumber: truck?.plateNumber ?? null,
|
||
truckDriverName: truck?.driverName ?? null,
|
||
truckType: truck?.truckType ?? null,
|
||
truckGateOut: truck?.departedAt ?? null,
|
||
// Per-truck load in tonnes: the summed VGM of the containers on this truck
|
||
// (recorded net for bulk); the weighed gross only as fallback.
|
||
truckWeightTons: truck
|
||
? Number(truck.truckWeightTons ?? 0) > 0
|
||
? Number(truck.truckWeightTons)
|
||
: this.grossAsTons(Number(truck.grossWeightKg ?? 0))
|
||
: null,
|
||
});
|
||
|
||
return {
|
||
filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Per-container (or bulk) items of a booking with their lifecycle stage and
|
||
* reference sources — drives the container-level detail datatable (stage tabs,
|
||
* multiselect load-to-truck, per-item actions).
|
||
*/
|
||
async containerItems(bookingId: string): Promise<
|
||
Array<{
|
||
containerNumber: string;
|
||
goods: string | null;
|
||
containerSize: string | null;
|
||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||
grnNumber: string | null;
|
||
truckAssignmentId: string | null;
|
||
truckPlate: string | null;
|
||
truckArrived: boolean;
|
||
truckLeft: boolean;
|
||
loaded: boolean;
|
||
bookingReference: string | null;
|
||
contractId: string | null;
|
||
hasLastMile: boolean;
|
||
handoverSigned: boolean;
|
||
inspectionStatus: string | null;
|
||
}>
|
||
> {
|
||
const rows: Array<{
|
||
containerNumber: string;
|
||
goods: string | null;
|
||
containerSize: string | null;
|
||
received: boolean;
|
||
grnNumber: string | null;
|
||
truckAssignmentId: string | null;
|
||
truckPlate: string | null;
|
||
truckArrived: boolean;
|
||
truckLeft: boolean;
|
||
loaded: boolean;
|
||
bookingReference: string | null;
|
||
contractId: string | null;
|
||
hasLastMile: boolean;
|
||
delivered: boolean;
|
||
inspectionStatus: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT bcu.container_number AS "containerNumber",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||
bc.container_size AS "containerSize",
|
||
bcu.received_to_port AS received,
|
||
bcu.grn_number AS "grnNumber",
|
||
ctc.assignment_id AS "truckAssignmentId",
|
||
a.plate_number AS "truckPlate",
|
||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||
b.reference AS "bookingReference",
|
||
b.contract_id AS "contractId",
|
||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
|
||
inv.inspection_status AS "inspectionStatus"
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc
|
||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||
JOIN freight.bookings b ON b.id = bc.booking_id
|
||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||
LEFT JOIN freight.customer_truck_containers ctc
|
||
ON ctc.container_number = bcu.container_number
|
||
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
|
||
LEFT JOIN freight.customer_truck_assignments a
|
||
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
|
||
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
|
||
LEFT JOIN freight.warehouse_inventory inv
|
||
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
|
||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||
ORDER BY bcu.container_number`,
|
||
[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,
|
||
containerSize: r.containerSize,
|
||
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||
stage: r.delivered
|
||
? 'DELIVERED'
|
||
: r.truckLeft
|
||
? 'LEFT'
|
||
: r.loaded
|
||
? 'LOADED'
|
||
: r.truckAssignmentId
|
||
? 'ASSIGNED'
|
||
: r.grnNumber
|
||
? 'GRN'
|
||
: r.received
|
||
? 'RECEIVED'
|
||
: 'PENDING',
|
||
grnNumber: r.grnNumber,
|
||
truckAssignmentId: r.truckAssignmentId,
|
||
truckPlate: r.truckPlate,
|
||
truckArrived: r.truckArrived,
|
||
truckLeft: r.truckLeft,
|
||
loaded: r.loaded,
|
||
bookingReference: r.bookingReference,
|
||
contractId: r.contractId,
|
||
hasLastMile: r.hasLastMile,
|
||
inspectionStatus: r.inspectionStatus,
|
||
handoverSigned,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Container numbers assigned to a truck (by plate) on this booking, from both
|
||
* haulage paths: customer self-haul (customer_truck_containers) and EDR
|
||
* last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased.
|
||
*/
|
||
private async truckAssignedContainers(bookingId: string, plate: string): Promise<string[]> {
|
||
const rows: Array<{ cn: string | null }> = await this.dataSource.query(
|
||
`SELECT UPPER(cc.container_number) AS cn
|
||
FROM freight.customer_truck_assignments a
|
||
JOIN freight.customer_truck_containers cc
|
||
ON cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||
WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
|
||
UNION
|
||
SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn
|
||
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
|
||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`,
|
||
[bookingId, plate],
|
||
);
|
||
return rows.map((r) => r.cn).filter((n): n is string => Boolean(n));
|
||
}
|
||
|
||
/**
|
||
* 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",
|
||
MAX(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
|
||
GROUP BY bcu.container_number
|
||
ORDER BY bcu.container_number`,
|
||
[bookingId],
|
||
);
|
||
return rows.map((r) => ({
|
||
containerNumber: r.containerNumber,
|
||
weightTons: Number(r.weightTons) || 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Per-truck exit paper: one paper covering the containers loaded on a specific
|
||
* customer truck (used when multiple trucks leave separately). Gated on the
|
||
* handover being signed and warehouse fees paid.
|
||
*/
|
||
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const [truck] = await this.dataSource.query(
|
||
`SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
|
||
a.driver_name AS "driverName", a.truck_type AS "truckType",
|
||
a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
|
||
b.reference AS "bookingReference", company.name AS "customerName"
|
||
FROM freight.customer_truck_assignments a
|
||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE a.id = $1 AND a.deleted_at IS NULL`,
|
||
[assignmentId],
|
||
);
|
||
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
|
||
|
||
if (!(await this.handover.isFullySigned(truck.bookingId))) {
|
||
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
|
||
}
|
||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||
`SELECT id FROM freight.warehouse_inventory
|
||
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||
[truck.bookingId],
|
||
);
|
||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||
|
||
const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT c.container_number AS "containerNumber",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||
(SELECT SUM(u.vgm_tons)
|
||
FROM freight.booking_container_units u
|
||
JOIN freight.booking_container bc
|
||
ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
||
WHERE u.container_number = c.container_number
|
||
AND bc.booking_id = c.booking_id
|
||
AND u.deleted_at IS NULL) AS "vgmTons"
|
||
FROM freight.customer_truck_containers c
|
||
JOIN freight.bookings b ON b.id = c.booking_id
|
||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
|
||
ORDER BY c.container_number`,
|
||
[assignmentId],
|
||
);
|
||
|
||
// Truck load in tonnes: summed container VGM; the weighed gross only as
|
||
// fallback (bulk trucks carry no containers).
|
||
const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0);
|
||
const html = this.buildTruckExitPaperHtml({
|
||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||
bookingReference: truck.bookingReference,
|
||
customerName: truck.customerName,
|
||
plateNumber: truck.plateNumber,
|
||
driverName: truck.driverName,
|
||
truckType: truck.truckType,
|
||
grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)),
|
||
gateOut: truck.departedAt,
|
||
containers,
|
||
});
|
||
return {
|
||
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
|
||
* assignment). Deliberately NOT gated on the handover: EDR handovers are
|
||
* generated at delivery — i.e. after the truck has already left — so there is
|
||
* nothing to sign at exit time. Warehouse-fee clearance still applies.
|
||
*/
|
||
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const [truck] = await this.dataSource.query(
|
||
`SELECT lm.booking_id AS "bookingId",
|
||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||
COALESCE(
|
||
v.assigned_driver_name,
|
||
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
|
||
) AS "driverName",
|
||
v.vehicle_type AS "truckType",
|
||
va.gross_weight_tons AS "grossWeightKg",
|
||
va.departed_at AS "departedAt",
|
||
b.reference AS "bookingReference",
|
||
company.name AS "customerName"
|
||
FROM freight.last_mile_vehicle_assignments va
|
||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE va.id = $1 AND va.deleted_at IS NULL`,
|
||
[assignmentId],
|
||
);
|
||
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
|
||
|
||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||
`SELECT id FROM freight.warehouse_inventory
|
||
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||
[truck.bookingId],
|
||
);
|
||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||
|
||
// Bulk trucks carry no containers — the table is then empty and the paper
|
||
// stands on the weighed gross alone.
|
||
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT vc.container_number AS "containerNumber",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||
FROM freight.last_mile_vehicle_containers vc
|
||
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
|
||
JOIN freight.bookings b ON b.id = lm.booking_id
|
||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
|
||
ORDER BY vc.container_number`,
|
||
[assignmentId],
|
||
);
|
||
|
||
const html = this.buildTruckExitPaperHtml({
|
||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||
bookingReference: truck.bookingReference,
|
||
customerName: truck.customerName,
|
||
plateNumber: truck.plateNumber,
|
||
driverName: truck.driverName ?? '-',
|
||
truckType: truck.truckType ?? '-',
|
||
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||
gateOut: truck.departedAt,
|
||
containers,
|
||
});
|
||
return {
|
||
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||
};
|
||
}
|
||
|
||
private buildTruckExitPaperHtml(data: {
|
||
reference: string;
|
||
bookingReference: string;
|
||
customerName: string | null;
|
||
plateNumber: string;
|
||
driverName: string;
|
||
truckType: string;
|
||
grossWeightKg: number;
|
||
gateOut: string | Date | null;
|
||
containers: Array<{ containerNumber: string; goods: string | null }>;
|
||
}): string {
|
||
const esc = (v: unknown) =>
|
||
String(v ?? '-').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
|
||
const rows: Array<[string, string]> = [
|
||
['Booking Reference', data.bookingReference],
|
||
['Customer / Consignee', data.customerName ?? '-'],
|
||
['Pickup Truck Plate', data.plateNumber],
|
||
['Driver', data.driverName],
|
||
['Truck Type', data.truckType],
|
||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
|
||
['Gate-Out Time', gateOut],
|
||
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
||
];
|
||
const containerRows = data.containers.length
|
||
? data.containers
|
||
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
|
||
.join('')
|
||
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
|
||
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
|
||
<style>
|
||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
|
||
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
|
||
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
|
||
th { background: #f8fafc; width: 34%; font-weight: 800; }
|
||
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
|
||
.ref strong { font-size: 16px; }
|
||
</style></head>
|
||
<body>
|
||
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Warehouse Release / Exit Paper</h1>
|
||
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
|
||
<div class="section">Release Particulars</div>
|
||
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
|
||
<div class="section">Containers Leaving on This Truck</div>
|
||
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
|
||
<tbody>${containerRows}</tbody></table>
|
||
</body></html>`;
|
||
}
|
||
|
||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const [row] = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
|
||
inv.quantity,
|
||
-- An unweighed item still reports the cargo weight it holds: fall
|
||
-- back to the item's container VGM, then the booking's declared
|
||
-- weight, so a GRN never prints "0 t" for goods that are present.
|
||
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
|
||
inv.volume,
|
||
inv.status,
|
||
inv.notes,
|
||
b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
b.status AS "bookingStatus",
|
||
b.freight_type AS "freightType",
|
||
b.trade_direction AS "tradeDirection",
|
||
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||
company.name AS "customerName",
|
||
company.tin AS "customerTin",
|
||
service_type.service_name AS "serviceType",
|
||
origin_yard.label AS "originYardLabel",
|
||
origin_yard.code AS "originYardCode",
|
||
destination_yard.label AS "destinationYardLabel",
|
||
destination_yard.code AS "destinationYardCode",
|
||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||
booking_container."containerSummary" AS "bookingContainerSummary",
|
||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||
wh.name AS "warehouseName",
|
||
wh.code AS "warehouseCode",
|
||
yard.name AS "yardName",
|
||
yard.code AS "yardCode",
|
||
zone.name AS "zoneName",
|
||
zone.code AS "zoneCode"
|
||
FROM freight.warehouse_inventory inv
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
|
||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||
LEFT JOIN LATERAL (
|
||
SELECT MIN(bc.container_number) AS container_number,
|
||
STRING_AGG(
|
||
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||
', '
|
||
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||
) AS "containerSummary"
|
||
FROM freight.booking_container bc
|
||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||
WHERE bc.booking_id = b.id
|
||
AND bc.deleted_at IS NULL
|
||
) booking_container ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT SUM(bcu.vgm_tons) AS tons
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc2
|
||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||
WHERE bc2.booking_id = b.id
|
||
AND bcu.deleted_at IS NULL
|
||
AND (container.container_number IS NULL
|
||
OR bcu.container_number = container.container_number)
|
||
) item_vgm ON true
|
||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[id],
|
||
);
|
||
if (!row) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
if (!row.grnNumber) {
|
||
throw new BadRequestException('GRN number is missing for this inventory item');
|
||
}
|
||
|
||
const html = this.buildGrnDocumentHtml({
|
||
grnNumber: row.grnNumber,
|
||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
|
||
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
|
||
bookingStatus: row.bookingStatus ?? null,
|
||
customerName: row.customerName ?? null,
|
||
customerTin: row.customerTin ?? null,
|
||
serviceType: row.serviceType ?? null,
|
||
freightType: row.freightType ?? null,
|
||
tradeDirection: row.tradeDirection ?? null,
|
||
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||
.filter(Boolean)
|
||
.join(' to ') || null,
|
||
containerNumber: row.containerNumber ?? null,
|
||
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||
cargoDescription: row.cargoDescription ?? null,
|
||
quantity: Number(row.quantity ?? 0),
|
||
weight: Number(row.weight ?? 0),
|
||
volume: row.volume == null ? null : Number(row.volume),
|
||
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
|
||
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||
inventoryStatus: row.status ?? null,
|
||
receiveSummary: this.extractReceiveSummary(row.notes),
|
||
});
|
||
|
||
return {
|
||
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||
// Styled fallback titled as a GRN (not a release order) for Chromium-less render.
|
||
buffer: await this.releaseDocuments.renderStyledDocument(
|
||
html,
|
||
{
|
||
titleLines: ['GOODS RECEIVED', 'NOTE'],
|
||
subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
|
||
sectionTitle: 'RECEIVED PARTICULARS',
|
||
refLabel: 'GRN No.',
|
||
},
|
||
'Goods Received Note',
|
||
),
|
||
};
|
||
}
|
||
|
||
async approveDeliveryForBooking(
|
||
bookingId: string,
|
||
userId?: string,
|
||
signerName?: string,
|
||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||
if (!userId) {
|
||
throw new BadRequestException('Authentication is required to approve delivery');
|
||
}
|
||
const name = signerName?.trim();
|
||
if (!name) {
|
||
throw new BadRequestException('Please enter your full name to approve delivery');
|
||
}
|
||
|
||
// A saved signature is applied when available; otherwise the typed full name
|
||
// is the record of who approved (self-haul customers may have no signature).
|
||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||
|
||
const [item]: Array<{
|
||
id: string;
|
||
warehouseId: string | null;
|
||
notes: string | null;
|
||
customerTruckAssignedAt: string | null;
|
||
customerTruckArrivedAt: string | null;
|
||
}> =
|
||
await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.warehouse_id AS "warehouseId",
|
||
inv.notes,
|
||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
|
||
FROM freight.warehouse_inventory inv
|
||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||
WHERE inv.booking_id = $1
|
||
AND inv.deleted_at IS NULL
|
||
AND inv.inspection_status = 'PASSED'
|
||
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
|
||
if (!item) {
|
||
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
|
||
}
|
||
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
|
||
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
|
||
}
|
||
await this.invoices.assertClearanceAllowed(item.id);
|
||
|
||
const approvedAt = new Date();
|
||
const signatureImageUrl = signature?.signatureImageUrl ?? null;
|
||
const approval = {
|
||
approvedAt: approvedAt.toISOString(),
|
||
signerDisplayName: name,
|
||
signatureImageUrl,
|
||
userId,
|
||
};
|
||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||
notes: this.appendNote(existingNotes, approvalNote),
|
||
});
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_RELEASED',
|
||
inventoryId: item.id,
|
||
warehouseId: item.warehouseId,
|
||
description: `Customer approved delivery as ${name}`,
|
||
performedBy: name,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||
// the truck leaves). Kept alongside the legacy approval note.
|
||
await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);
|
||
|
||
return {
|
||
bookingId,
|
||
inventoryId: item.id,
|
||
approvedAt: approval.approvedAt,
|
||
signerDisplayName: name,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 signature = await this.signatures.getForUser(userId).catch(() => null);
|
||
|
||
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`);
|
||
// Self-haul stays a single booking-level signature via approve-delivery,
|
||
// which also enforces inspection-passed + truck-arrived. Per-truck signing
|
||
// is an EDR last-mile flow only.
|
||
if (h.mileType !== 'EDR_LAST_MILE') {
|
||
throw new BadRequestException(
|
||
'This handover is signed through Approve delivery, not per truck',
|
||
);
|
||
}
|
||
|
||
// 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, signature?.signatureImageUrl ?? null);
|
||
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
|
||
ORDER BY updated_at DESC NULLS LAST, created_at DESC
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
if (!inv) {
|
||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||
}
|
||
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,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Record whether a booking's goods needed double handling. Answered by
|
||
* warehouse staff once the goods are unloaded — only Yes bills the
|
||
* DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling).
|
||
* Locked once the fee has been invoiced, so a billed charge can't be
|
||
* retro-cancelled from the operations screen.
|
||
*/
|
||
async setDoubleHandling(
|
||
bookingId: string,
|
||
doubleHandling: boolean,
|
||
performedBy?: string,
|
||
): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> {
|
||
const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> =
|
||
await this.dataSource.query(
|
||
`SELECT id, trade_direction AS "tradeDirection", reference
|
||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
|
||
throw new BadRequestException('Double handling applies to import bookings only');
|
||
}
|
||
|
||
// Warehouse fees are billed per inventory row (invoices.source = 'warehouse',
|
||
// source_id = the inventory id), with the fee type on the line's charge_type.
|
||
const [invoiced]: Array<{ one: number }> = await this.dataSource.query(
|
||
`SELECT 1 AS one
|
||
FROM freight.invoices i
|
||
JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL
|
||
JOIN freight.warehouse_inventory inv
|
||
ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
|
||
WHERE inv.booking_id = $1
|
||
AND i.source = 'warehouse'
|
||
AND i.deleted_at IS NULL
|
||
AND i.status <> 'CANCELLED'
|
||
AND il.charge_type = 'DOUBLE_HANDLING'
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
if (invoiced) {
|
||
throw new BadRequestException(
|
||
'Double handling has already been invoiced for this booking — cancel the invoice to change it',
|
||
);
|
||
}
|
||
|
||
const setAt = new Date();
|
||
await this.dataSource.query(
|
||
`UPDATE freight.bookings
|
||
SET double_handling = $2,
|
||
double_handling_set_at = $3,
|
||
double_handling_set_by = $4,
|
||
updated_at = NOW()
|
||
WHERE id = $1`,
|
||
[bookingId, doubleHandling, setAt, performedBy ?? null],
|
||
);
|
||
|
||
// Audit on the booking's inventory rows so it shows in warehouse history.
|
||
const items: 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`,
|
||
[bookingId],
|
||
);
|
||
for (const item of items) {
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_STORED',
|
||
inventoryId: item.id,
|
||
warehouseId: item.warehouseId,
|
||
description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`,
|
||
performedBy,
|
||
});
|
||
}
|
||
|
||
return { bookingId, doubleHandling, setAt: setAt.toISOString() };
|
||
}
|
||
|
||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||
`SELECT id 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`,
|
||
[bookingId],
|
||
);
|
||
if (!inv) {
|
||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||
}
|
||
return inv.id;
|
||
}
|
||
|
||
/** Booking-scoped GRN document (customer portal). */
|
||
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||
}
|
||
|
||
/** Booking-scoped gate-clearance / release document (customer portal). */
|
||
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||
}
|
||
|
||
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",
|
||
inv.quantity,
|
||
-- An unweighed item still reports the cargo weight it holds: fall
|
||
-- back to the item's container VGM when no weight was recorded.
|
||
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
|
||
inv.status,
|
||
inv.notes,
|
||
inv.inspection_status AS "inspectionStatus",
|
||
inv.release_date AS "releaseDate",
|
||
inv.release_order_reference AS "releaseOrderReference",
|
||
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
|
||
b.reference AS "bookingReference",
|
||
b.status AS "bookingStatus",
|
||
b.freight_type AS "freightType",
|
||
b.trade_direction AS "tradeDirection",
|
||
b.scheduled_date AS "scheduledDate",
|
||
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
|
||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||
company.name AS "customerName",
|
||
service_type.service_name AS "serviceType",
|
||
origin_yard.label AS "originYardLabel",
|
||
origin_yard.code AS "originYardCode",
|
||
destination_yard.label AS "destinationYardLabel",
|
||
destination_yard.code AS "destinationYardCode",
|
||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||
booking_container."containerSummary" AS "bookingContainerSummary",
|
||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||
wh.name AS "warehouseName",
|
||
wh.code AS "warehouseCode",
|
||
yard.name AS "yardName",
|
||
yard.code AS "yardCode",
|
||
zone.name AS "zoneName",
|
||
zone.code AS "zoneCode",
|
||
ts.train_number AS "trainSchedule"
|
||
FROM freight.warehouse_inventory inv
|
||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
|
||
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
|
||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||
LEFT JOIN LATERAL (
|
||
SELECT MIN(bc.container_number) AS container_number,
|
||
STRING_AGG(
|
||
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
|
||
', '
|
||
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
|
||
) AS "containerSummary"
|
||
FROM freight.booking_container bc
|
||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||
WHERE bc.booking_id = b.id
|
||
AND bc.deleted_at IS NULL
|
||
) booking_container ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT SUM(bcu.vgm_tons) AS tons
|
||
FROM freight.booking_container_units bcu
|
||
JOIN freight.booking_container bc2
|
||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||
WHERE bc2.booking_id = b.id
|
||
AND bcu.deleted_at IS NULL
|
||
AND (container.container_number IS NULL
|
||
OR bcu.container_number = container.container_number)
|
||
) item_vgm ON true
|
||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[id],
|
||
);
|
||
if (!row) {
|
||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||
}
|
||
await this.invoices.assertClearanceAllowed(id);
|
||
if (row.inspectionStatus !== 'PASSED') {
|
||
throw new BadRequestException('Handover document is available after inspection has passed');
|
||
}
|
||
|
||
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;
|
||
// 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)),
|
||
});
|
||
}
|
||
|
||
const html = this.buildHandoverDocumentHtml({
|
||
reference,
|
||
handedOverAt,
|
||
stampImageUrl: await this.stampSettings.getStampImageUrl(),
|
||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||
bookingReference,
|
||
bookingStatus: row.bookingStatus ?? null,
|
||
customerName: row.customerName ?? null,
|
||
serviceType: row.serviceType ?? null,
|
||
freightType: row.freightType ?? null,
|
||
tradeDirection: row.tradeDirection ?? null,
|
||
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
|
||
.filter(Boolean)
|
||
.join(' to ') || null,
|
||
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
|
||
containerNumber: row.containerNumber ?? null,
|
||
bookingContainerSummary: row.bookingContainerSummary ?? null,
|
||
cargoDescription: row.cargoDescription ?? null,
|
||
quantity: Number(row.quantity ?? 0),
|
||
weight: Number(row.weight ?? 0),
|
||
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
|
||
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||
inventoryStatus: row.status ?? null,
|
||
inspectionStatus: row.inspectionStatus ?? null,
|
||
releaseOrderReference: row.releaseOrderReference ?? null,
|
||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||
trainSchedule: row.trainSchedule ?? null,
|
||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||
truckPlate: perTruck?.truckPlate ?? null,
|
||
customerApproval: perTruck
|
||
? perTruck.signedAt
|
||
? {
|
||
approvedAt: perTruck.signedAt.toISOString(),
|
||
signerDisplayName: perTruck.signerName ?? '-',
|
||
signatureImageUrl: null,
|
||
}
|
||
: null
|
||
: this.extractCustomerDeliveryApproval(row.notes),
|
||
});
|
||
|
||
return {
|
||
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||
// Styled fallback titled as a handover (not a release order) for Chromium-less render.
|
||
buffer: await this.releaseDocuments.renderStyledDocument(
|
||
html,
|
||
{
|
||
titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
|
||
subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
|
||
sectionTitle: 'HANDOVER PARTICULARS',
|
||
refLabel: 'Document / Handover No.',
|
||
},
|
||
'Import Goods Handover',
|
||
),
|
||
};
|
||
}
|
||
|
||
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
|
||
const item = await this.findById(id);
|
||
this.assertTransition(item.status, 'DELIVERED');
|
||
|
||
if (!item.releaseDate) {
|
||
throw new BadRequestException('A release order must be issued before the goods can be delivered');
|
||
}
|
||
|
||
// Self-haul: the customer's own truck delivers — deliver only after the
|
||
// handover is signed AND the truck has left the warehouse holding the goods.
|
||
if (item.bookingId) {
|
||
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
|
||
`SELECT customer_truck_assigned_at AS "assignedAt"
|
||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
if (sh?.assignedAt) {
|
||
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
||
throw new BadRequestException('Handover must be signed before delivery');
|
||
}
|
||
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||
`SELECT COUNT(*) AS total,
|
||
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
|
||
FROM freight.customer_truck_assignments
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
const totalTrucks = Number(trucks?.total ?? 0);
|
||
const leftTrucks = Number(trucks?.left ?? 0);
|
||
if (leftTrucks === 0) {
|
||
throw new BadRequestException('Deliver is available only after the customer truck has left');
|
||
}
|
||
// Multi-truck booking: every assigned truck must arrive and leave —
|
||
// each is weighed out separately before the goods count as delivered.
|
||
if (leftTrucks < totalTrucks) {
|
||
throw new BadRequestException(
|
||
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
|
||
);
|
||
}
|
||
}
|
||
// EDR last-mile delivers per truck: a container item only needs the truck
|
||
// CARRYING IT to have left; bulk (no container) waits for every truck.
|
||
if (item.containerId) {
|
||
const [own]: Array<{ pending: string }> = await this.dataSource.query(
|
||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending
|
||
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
|
||
JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL
|
||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||
AND COALESCE(vc.container_number, va.container_number) = c.container_number`,
|
||
[item.bookingId, item.containerId],
|
||
);
|
||
if (Number(own?.pending ?? 0) > 0) {
|
||
throw new BadRequestException(
|
||
'Deliver is available only after the EDR truck carrying this container has left',
|
||
);
|
||
}
|
||
} else {
|
||
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();
|
||
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
||
const weight = Number(item.weight) || 0;
|
||
const volume = Number(item.volume) || 0;
|
||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(id, {
|
||
status: 'DELIVERED',
|
||
deliveredAt,
|
||
});
|
||
|
||
// Goods physically leave the warehouse on pickup — free up capacity.
|
||
await this.applyCapacityDelta(
|
||
manager,
|
||
{
|
||
warehouseId: item.warehouseId,
|
||
yardId: item.yardId,
|
||
zoneId: item.zoneId,
|
||
},
|
||
-weight,
|
||
-volume,
|
||
-containerCount,
|
||
);
|
||
|
||
// Proof of delivery is captured on the linked cargo.
|
||
if (item.cargoId) {
|
||
await manager.getRepository(Cargo).update(item.cargoId, {
|
||
receiverName,
|
||
deliveredAt,
|
||
deliveryRemarks: dto.remarks?.trim() ?? null,
|
||
});
|
||
}
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_DELIVERED',
|
||
inventoryId: id,
|
||
warehouseId: item.warehouseId,
|
||
description: `Delivered to ${receiverName}`,
|
||
performedBy: dto.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
|
||
// Handover on delivery. EDR last-mile generates its handover HERE (after
|
||
// exit, on delivery). Self-haul handovers were generated on arrival —
|
||
// stamp them delivered.
|
||
if (item.bookingId) {
|
||
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
|
||
`SELECT customer_truck_assigned_at AS "selfHaul"
|
||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
if (b?.selfHaul) {
|
||
await manager.query(
|
||
`UPDATE freight.booking_handovers
|
||
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
} else {
|
||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||
// vehicle from the truck's own container list (the earlier lookup went
|
||
// through last_mile_container_allocations, which nothing ever writes —
|
||
// so truckPlate was always null and every booking collapsed to a single
|
||
// booking-level handover). Bulk has no container, so fall back to the
|
||
// delivery's single truck; a booking-level handover when unresolvable.
|
||
let truckPlate: string | null = null;
|
||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||
FROM freight.last_mile_vehicle_assignments va
|
||
JOIN freight.last_mile lm
|
||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||
LEFT JOIN freight.containers cont
|
||
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||
WHERE lm.booking_id = $1
|
||
AND va.deleted_at IS NULL
|
||
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||
LIMIT 1`,
|
||
[item.bookingId, item.containerId ?? null],
|
||
);
|
||
truckPlate = veh?.plate ?? null;
|
||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||
}
|
||
}
|
||
});
|
||
|
||
// "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);
|
||
}
|
||
|
||
/**
|
||
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
|
||
* Reads wagon/schedule data read-only — never modifies scheduling.
|
||
*/
|
||
async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
|
||
const item = await this.findById(id);
|
||
|
||
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||
this.assertTransition(item.status, 'LOADED');
|
||
|
||
// 2. inventory is at a valid warehouse/yard/zone location.
|
||
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||
}
|
||
|
||
// 3. wagon must exist.
|
||
const wagon = await this.scheduling.findWagon(dto.wagonId);
|
||
if (!wagon) {
|
||
throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||
}
|
||
|
||
// 4. wagon must be available, or already selected by an existing train schedule.
|
||
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
|
||
if (!isLoadableWagonStatus(wagon.status) && !scheduled) {
|
||
throw new BadRequestException(
|
||
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
|
||
);
|
||
}
|
||
|
||
// 5. inventory must not already have a loading record.
|
||
const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
|
||
if (existing.length > 0) {
|
||
throw new BadRequestException('Inventory has already been loaded');
|
||
}
|
||
|
||
const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const now = new Date();
|
||
await manager.getRepository(WarehouseInventory).update(id, {
|
||
status: 'LOADED',
|
||
loadedAt: now,
|
||
});
|
||
|
||
await manager.getRepository(WarehouseLoading).save(
|
||
manager.getRepository(WarehouseLoading).create({
|
||
warehouseInventoryId: id,
|
||
bookingId: item.bookingId ?? null,
|
||
wagonId: dto.wagonId,
|
||
// Which train this load belongs to — durable even if wagons reshuffle.
|
||
trainScheduleId: dto.trainScheduleId ?? null,
|
||
loadedAt: now,
|
||
loadedBy: dto.loadedBy ?? null,
|
||
loadedWeight,
|
||
notes: dto.notes?.trim() ?? null,
|
||
}),
|
||
);
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: 'INVENTORY_LOADED',
|
||
inventoryId: id,
|
||
warehouseId: item.warehouseId,
|
||
description: `Loaded onto wagon ${wagon.wagonNumber}`,
|
||
performedBy: dto.loadedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
// ── Loading records (Batch 3) ─────────────────────────────────────────────
|
||
|
||
async findLoadings(
|
||
filter: { bookingId?: string; wagonId?: string },
|
||
): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
|
||
const where = {
|
||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||
...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
|
||
};
|
||
const loadings = await this.loadingRepository.findAll({
|
||
where,
|
||
relations: { inventory: { warehouse: true, yard: true, zone: true } },
|
||
order: { loadedAt: 'DESC' },
|
||
});
|
||
|
||
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
||
const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))];
|
||
const wagonNumbers = new Map<string, string>();
|
||
if (wagonIds.length > 0) {
|
||
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
||
'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
|
||
[wagonIds],
|
||
);
|
||
rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
|
||
}
|
||
|
||
return loadings.map((loading) =>
|
||
Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }),
|
||
);
|
||
}
|
||
|
||
findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
|
||
return this.loadingRepository.findAll({
|
||
where: { warehouseInventoryId: inventoryId },
|
||
order: { loadedAt: 'DESC' },
|
||
});
|
||
}
|
||
|
||
dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||
return this.transition(id, 'DISPATCHED', {
|
||
timestampField: 'dispatchedAt',
|
||
activityType: 'INVENTORY_DISPATCHED',
|
||
description: 'Inventory dispatched',
|
||
performedBy,
|
||
freeCapacity: true,
|
||
});
|
||
}
|
||
|
||
async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise<WarehouseDashboardSummary> {
|
||
const warehouses = await this.dataSource.getRepository(Warehouse).find({
|
||
where: {
|
||
status: 'ACTIVE',
|
||
...(filter.facilityId ? { stationId: filter.facilityId } : {}),
|
||
...(filter.warehouseId ? { id: filter.warehouseId } : {}),
|
||
},
|
||
});
|
||
const inventory = await this.findAll(filter);
|
||
const today = new Date();
|
||
|
||
const byStatus = inventory.reduce<Record<string, number>>((acc, item) => {
|
||
acc[item.status] = (acc[item.status] ?? 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
|
||
return {
|
||
totalWarehouses: warehouses.length,
|
||
totalInventory: inventory.length,
|
||
receivedToday: inventory.filter((item) => {
|
||
const arrivedAt = item.arrivedAt ?? item.createdAt;
|
||
return (
|
||
arrivedAt.getFullYear() === today.getFullYear() &&
|
||
arrivedAt.getMonth() === today.getMonth() &&
|
||
arrivedAt.getDate() === today.getDate()
|
||
);
|
||
}).length,
|
||
stored: byStatus.STORED ?? 0,
|
||
reserved: byStatus.RESERVED ?? 0,
|
||
readyForLoading: byStatus.READY_FOR_LOADING ?? 0,
|
||
loaded: byStatus.LOADED ?? 0,
|
||
dispatched: byStatus.DISPATCHED ?? 0,
|
||
};
|
||
}
|
||
|
||
findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
|
||
return this.dataSource.getRepository(WarehouseInventoryMovement).find({
|
||
where: { inventoryId: id },
|
||
order: { movedAt: 'DESC' },
|
||
});
|
||
}
|
||
|
||
findActivity(id: string): Promise<WarehouseActivityLog[]> {
|
||
return this.activityLog.findByInventory(id);
|
||
}
|
||
|
||
// ── Inquiry (Batch 1) ──────────────────────────────────────────────────
|
||
|
||
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
|
||
const bookingReference = (filter.bookingReference ?? filter.bookingNumber)?.trim();
|
||
if (bookingReference) {
|
||
const params: unknown[] = [`%${bookingReference}%`];
|
||
const where = ['b.reference ILIKE $1', 'b.deleted_at IS NULL'];
|
||
|
||
if (filter.containerNumber?.trim()) {
|
||
params.push(`%${filter.containerNumber.trim()}%`);
|
||
where.push(`container.container_number ILIKE $${params.length}`);
|
||
}
|
||
if (filter.cargoType?.trim()) {
|
||
params.push(`%${filter.cargoType.trim()}%`);
|
||
where.push(`cargo_type.cargo_type_name ILIKE $${params.length}`);
|
||
}
|
||
if (filter.goodsName?.trim()) {
|
||
params.push(`%${filter.goodsName.trim()}%`);
|
||
where.push(`(inv.notes ILIKE $${params.length} OR cargo.description ILIKE $${params.length})`);
|
||
}
|
||
if (filter.warehouseId) {
|
||
params.push(filter.warehouseId);
|
||
where.push(`inv.warehouse_id = $${params.length}`);
|
||
}
|
||
if (filter.yardId) {
|
||
params.push(filter.yardId);
|
||
where.push(`inv.yard_id = $${params.length}`);
|
||
}
|
||
if (filter.zoneId) {
|
||
params.push(filter.zoneId);
|
||
where.push(`inv.zone_id = $${params.length}`);
|
||
}
|
||
if (filter.status) {
|
||
params.push(filter.status);
|
||
where.push(`inv.status = $${params.length}`);
|
||
}
|
||
|
||
const rows = await this.dataSource.query(
|
||
`SELECT COALESCE(inv.id::text, b.id::text) AS "id",
|
||
inv.id AS "inventoryId",
|
||
b.id AS "bookingId",
|
||
b.reference AS "bookingReference",
|
||
b.reference AS "bookingNumber",
|
||
b.status AS "bookingStatus",
|
||
company.name AS "customerName",
|
||
container.container_number AS "containerNumber",
|
||
cargo_type.cargo_type_name AS "cargoType",
|
||
cargo.description AS "cargoDescription",
|
||
inv.goods_id AS "goodsId",
|
||
wh.id AS "warehouseId",
|
||
wh.name AS "warehouseName",
|
||
wh.code AS "warehouseCode",
|
||
yard.id AS "yardId",
|
||
yard.name AS "yardName",
|
||
yard.code AS "yardCode",
|
||
zone.id AS "zoneId",
|
||
zone.name AS "zoneName",
|
||
zone.code AS "zoneCode",
|
||
inv.status,
|
||
ts.train_number AS "trainNumber",
|
||
ts.status AS "trainStatus",
|
||
oy.code AS "originCode",
|
||
dy.code AS "destinationCode",
|
||
CASE
|
||
WHEN inv.id IS NOT NULL THEN concat_ws(' / ', wh.code, yard.code, zone.code)
|
||
WHEN ts.status = 'ARRIVED' THEN concat('Arrived at ', COALESCE(dy.code, 'destination'), ' - awaiting unload')
|
||
WHEN ts.status = 'DISPATCHED' THEN concat('In transit: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?'))
|
||
WHEN ts.id IS NOT NULL THEN concat('Scheduled: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?'))
|
||
ELSE 'No warehouse inventory yet'
|
||
END AS "locationSummary",
|
||
COALESCE(inv.quantity, 0) AS quantity,
|
||
COALESCE(inv.weight, b.cargo_total_weight_vgm, 0) AS weight,
|
||
inv.arrived_at AS "arrivedAt",
|
||
inv.ready_for_loading_at AS "readyForLoadingAt"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||
LEFT JOIN freight.containers container ON (
|
||
(inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
||
OR (inv.container_id IS NULL AND container.booking_id = b.id)
|
||
) AND container.deleted_at IS NULL
|
||
LEFT JOIN freight.cargoes cargo ON (
|
||
(inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
||
OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id)
|
||
) AND cargo.deleted_at IS NULL
|
||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||
LEFT JOIN LATERAL (
|
||
SELECT ts_inner.*
|
||
FROM freight.train_schedule_bookings tsb
|
||
JOIN freight.train_schedules ts_inner ON ts_inner.id = tsb.train_schedule_id
|
||
WHERE tsb.booking_id = b.id
|
||
AND tsb.deleted_at IS NULL
|
||
AND ts_inner.deleted_at IS NULL
|
||
ORDER BY ts_inner.scheduled_departure_date DESC NULLS LAST
|
||
LIMIT 1
|
||
) ts ON TRUE
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ${where.join(' AND ')}
|
||
ORDER BY inv.created_at DESC NULLS LAST, b.created_at DESC`,
|
||
params,
|
||
);
|
||
|
||
return rows.map((row: Record<string, unknown>) => ({
|
||
id: String(row.id),
|
||
inventoryId: (row.inventoryId as string | null) ?? null,
|
||
bookingId: (row.bookingId as string | null) ?? null,
|
||
bookingReference: (row.bookingReference as string | null) ?? null,
|
||
bookingNumber: (row.bookingNumber as string | null) ?? null,
|
||
bookingStatus: (row.bookingStatus as string | null) ?? null,
|
||
customerName: (row.customerName as string | null) ?? null,
|
||
containerNumber: (row.containerNumber as string | null) ?? null,
|
||
cargoType: (row.cargoType as string | null) ?? null,
|
||
cargoDescription: (row.cargoDescription as string | null) ?? null,
|
||
goodsId: (row.goodsId as string | null) ?? null,
|
||
warehouse: row.warehouseId
|
||
? { id: row.warehouseId as string, name: row.warehouseName as string, code: row.warehouseCode as string }
|
||
: null,
|
||
yard: row.yardId
|
||
? { id: row.yardId as string, name: row.yardName as string, code: row.yardCode as string }
|
||
: null,
|
||
zone: row.zoneId
|
||
? { id: row.zoneId as string, name: row.zoneName as string, code: row.zoneCode as string }
|
||
: null,
|
||
status: (row.status as string | null) ?? null,
|
||
trainNumber: (row.trainNumber as string | null) ?? null,
|
||
trainStatus: (row.trainStatus as string | null) ?? null,
|
||
route:
|
||
row.originCode || row.destinationCode
|
||
? `${row.originCode ?? '?'} -> ${row.destinationCode ?? '?'}`
|
||
: null,
|
||
locationSummary: (row.locationSummary as string | null) ?? null,
|
||
quantity: Number(row.quantity) || 0,
|
||
weight: Number(row.weight) || 0,
|
||
arrivedAt: (row.arrivedAt as Date | null) ?? null,
|
||
readyForLoadingAt: (row.readyForLoadingAt as Date | null) ?? null,
|
||
}));
|
||
}
|
||
|
||
const qb = this.dataSource
|
||
.getRepository(WarehouseInventory)
|
||
.createQueryBuilder('inv')
|
||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||
.leftJoinAndSelect('inv.yard', 'yard')
|
||
.leftJoinAndSelect('inv.zone', 'zone')
|
||
// Entity-class joins: TypeORM parses a raw 'freight.table' string as an
|
||
// alias.property path ("freight" alias was not found) — runtime 500.
|
||
.leftJoin(Booking, 'booking', 'booking.id = inv.booking_id')
|
||
.leftJoin(Company, 'company', 'company.id = booking.company_id')
|
||
.leftJoin(
|
||
Container,
|
||
'container',
|
||
`((inv.container_id IS NOT NULL AND container.id = inv.container_id)
|
||
OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
|
||
AND container.deleted_at IS NULL`,
|
||
)
|
||
.leftJoin(
|
||
Cargo,
|
||
'cargo',
|
||
`((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
|
||
OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
|
||
AND cargo.deleted_at IS NULL`,
|
||
)
|
||
.leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||
.addSelect('booking.reference', 'b_reference')
|
||
.addSelect('company.name', 'c_name')
|
||
.addSelect('container.container_number', 'ct_number')
|
||
.addSelect('cargo.description', 'cg_description')
|
||
.addSelect('cargo_type.cargo_type_name', 'cgt_name')
|
||
.orderBy('inv.created_at', 'DESC');
|
||
|
||
if (filter.containerNumber?.trim()) {
|
||
qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` });
|
||
}
|
||
if (filter.cargoType?.trim()) {
|
||
qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
|
||
}
|
||
if (filter.goodsName?.trim()) {
|
||
qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` });
|
||
}
|
||
if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId });
|
||
if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId });
|
||
if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId });
|
||
if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status });
|
||
|
||
const { entities, raw } = await qb.getRawAndEntities();
|
||
|
||
return entities.map((inv, index) => {
|
||
const row = raw[index] ?? {};
|
||
return {
|
||
id: inv.id,
|
||
inventoryId: inv.id,
|
||
bookingId: inv.bookingId ?? null,
|
||
bookingReference: row.b_reference ?? null,
|
||
bookingNumber: row.b_reference ?? null,
|
||
bookingStatus: null,
|
||
customerName: row.c_name ?? null,
|
||
containerNumber: row.ct_number ?? null,
|
||
cargoType: row.cgt_name ?? null,
|
||
cargoDescription: row.cg_description ?? null,
|
||
goodsId: inv.goodsId ?? null,
|
||
warehouse: inv.warehouse
|
||
? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code }
|
||
: null,
|
||
yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null,
|
||
zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null,
|
||
status: inv.status,
|
||
trainNumber: null,
|
||
trainStatus: null,
|
||
route: null,
|
||
locationSummary: inv.warehouse
|
||
? [inv.warehouse.code, inv.yard?.code, inv.zone?.code].filter(Boolean).join(' / ')
|
||
: null,
|
||
quantity: Number(inv.quantity),
|
||
weight: Number(inv.weight),
|
||
arrivedAt: inv.arrivedAt ?? null,
|
||
readyForLoadingAt: inv.readyForLoadingAt ?? null,
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
private async transition(
|
||
id: string,
|
||
to: WarehouseInventoryStatus,
|
||
opts: {
|
||
timestampField: keyof WarehouseInventory;
|
||
activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
|
||
description: string;
|
||
performedBy?: string;
|
||
preloaded?: WarehouseInventory;
|
||
/** Cargo physically leaves the warehouse on this transition — free up capacity (mirrors deliver()). */
|
||
freeCapacity?: boolean;
|
||
},
|
||
): Promise<WarehouseInventory> {
|
||
const item = opts.preloaded ?? (await this.findById(id));
|
||
this.assertTransition(item.status, to);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(id, {
|
||
status: to,
|
||
[opts.timestampField]: new Date(),
|
||
});
|
||
|
||
if (opts.freeCapacity) {
|
||
const weight = Number(item.weight) || 0;
|
||
const volume = Number(item.volume) || 0;
|
||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||
await this.applyCapacityDelta(
|
||
manager,
|
||
{ warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId },
|
||
-weight,
|
||
-volume,
|
||
-containerCount,
|
||
);
|
||
}
|
||
|
||
await this.activityLog.record(
|
||
{
|
||
activityType: opts.activityType,
|
||
inventoryId: id,
|
||
warehouseId: item.warehouseId,
|
||
description: opts.description,
|
||
performedBy: opts.performedBy,
|
||
},
|
||
manager,
|
||
);
|
||
});
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
private buildGrnDocumentHtml(data: {
|
||
grnNumber: string;
|
||
receivedAt: Date;
|
||
bookingReference: string;
|
||
bookingStatus: string | null;
|
||
customerName: string | null;
|
||
customerTin: string | null;
|
||
serviceType: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
route: string | null;
|
||
containerNumber: string | null;
|
||
bookingContainerSummary: string | null;
|
||
cargoDescription: string | null;
|
||
quantity: number;
|
||
weight: number;
|
||
volume: number | null;
|
||
bookingDeclaredWeight: number;
|
||
warehouse: string | null;
|
||
yard: string | null;
|
||
zone: string | null;
|
||
inventoryStatus: string | null;
|
||
receiveSummary: string | null;
|
||
/** The one global company logo; null renders the plain text brand. */
|
||
logoImageUrl?: string | null;
|
||
}): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
const rows: Array<[string, unknown]> = [
|
||
['Booking Reference', data.bookingReference],
|
||
// The GRN is mapped to the owner (import: consignee, export: shipper) —
|
||
// named explicitly so the note reads the same for both directions.
|
||
["Owner's Name", data.customerName],
|
||
['Customer TIN', data.customerTin],
|
||
['Booking Status', data.bookingStatus],
|
||
['Service Type', data.serviceType],
|
||
['Freight Type', data.freightType],
|
||
['Trade Direction', data.tradeDirection],
|
||
['Route', data.route],
|
||
['Container Number', data.containerNumber],
|
||
['Booking Containers', data.bookingContainerSummary],
|
||
['Cargo / Goods Description', data.cargoDescription],
|
||
['Quantity', data.quantity],
|
||
['Received Weight', `${data.weight.toLocaleString()} t`],
|
||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||
['Warehouse', data.warehouse],
|
||
['Yard', data.yard],
|
||
['Zone', data.zone],
|
||
['Inventory Status', data.inventoryStatus],
|
||
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
|
||
];
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Goods Received Note</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
|
||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
|
||
${logoImageCss()}
|
||
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
|
||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
|
||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
|
||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
${logoMarkup(data.logoImageUrl)}
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Goods Received Note</h1>
|
||
<div class="subtitle">Warehouse receiving confirmation</div>
|
||
</div>
|
||
<div class="ref">
|
||
GRN Number
|
||
<strong>${esc(data.grnNumber)}</strong>
|
||
Received: ${esc(receivedAt)}
|
||
</div>
|
||
</div>
|
||
<div class="rule"></div>
|
||
<div class="notice">
|
||
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
|
||
</div>
|
||
<div class="section-title">Receiving Particulars</div>
|
||
<table>
|
||
<tbody>
|
||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
<div class="section-title">Receipt Clause</div>
|
||
<div class="clause">
|
||
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
|
||
</div>
|
||
<div class="signatures">
|
||
<div class="line">Warehouse receiver name / signature / date</div>
|
||
<div class="line">Driver or customer representative name / signature / date</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private buildReleaseDocumentHtml(data: {
|
||
reference: string;
|
||
issuedAt: Date;
|
||
bookingReference: string;
|
||
bookingStatus: string | null;
|
||
customerName: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
containerNumber: string | null;
|
||
cargoDescription: string | null;
|
||
quantity: number;
|
||
weight: number;
|
||
warehouse: string | null;
|
||
yard: string | null;
|
||
zone: string | null;
|
||
inventoryStatus: string | null;
|
||
clearanceStatus: string;
|
||
exitInspectionSummary?: string | null;
|
||
truckPlateNumber?: string | null;
|
||
truckDriverName?: string | null;
|
||
truckType?: string | null;
|
||
truckGateOut?: string | null;
|
||
truckWeightTons?: number | null;
|
||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||
stampImageUrl?: string | null;
|
||
/** The one global company logo; null renders the plain text brand. */
|
||
logoImageUrl?: string | null;
|
||
}): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const issuedAt = data.issuedAt.toLocaleString('en-GB', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
const rows = [
|
||
['Booking Reference', data.bookingReference],
|
||
['Customer / Consignee', data.customerName],
|
||
['Booking Status', data.bookingStatus],
|
||
['Freight Type', data.freightType],
|
||
['Trade Direction', data.tradeDirection],
|
||
['Container Number', data.containerNumber],
|
||
['Cargo / Goods Description', data.cargoDescription],
|
||
['Quantity', data.quantity],
|
||
[
|
||
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
|
||
`${(data.truckPlateNumber && data.truckWeightTons
|
||
? data.truckWeightTons
|
||
: data.weight
|
||
).toLocaleString()} t`,
|
||
],
|
||
['Warehouse', data.warehouse],
|
||
['Yard', data.yard],
|
||
['Zone', data.zone],
|
||
['Inventory Status', data.inventoryStatus],
|
||
['Clearance Status', data.clearanceStatus],
|
||
...(data.truckPlateNumber
|
||
? ([
|
||
['Pickup Truck Plate', data.truckPlateNumber],
|
||
['Truck Driver', data.truckDriverName],
|
||
['Truck Type', data.truckType],
|
||
[
|
||
'Gate-Out Time',
|
||
data.truckGateOut
|
||
? new Date(data.truckGateOut).toLocaleString('en-GB', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
: null,
|
||
],
|
||
] as [string, string | null][])
|
||
: []),
|
||
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||
];
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Warehouse Release / Exit Paper</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||
.doc { position: relative; padding: 0; }
|
||
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
|
||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||
h1 { margin: 8px 0 0; max-width: 360px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
|
||
.notice { width: 74%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
|
||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
|
||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
|
||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
|
||
.seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||
.seal span { position: relative; }
|
||
${sealImageCss()}
|
||
${logoImageCss()}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="doc">
|
||
<div class="top">
|
||
<div>
|
||
${logoMarkup(data.logoImageUrl)}
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Warehouse Release / Exit Paper</h1>
|
||
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
|
||
</div>
|
||
<div class="ref">
|
||
Document / Release No.
|
||
<strong>${esc(data.reference)}</strong>
|
||
Issued: ${esc(issuedAt)}
|
||
</div>
|
||
</div>
|
||
<div class="rule"></div>
|
||
<div class="notice">
|
||
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
|
||
</div>
|
||
<div class="section-title">Release Particulars</div>
|
||
<table>
|
||
<tbody>
|
||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
<div class="section-title">Authorization Clause</div>
|
||
<div class="clause">
|
||
The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity,
|
||
cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
|
||
</div>
|
||
<div class="signatures">
|
||
<div class="line">Officer in charge name / signature / date</div>
|
||
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}</div>
|
||
<div class="line">Customer or driver name / signature / date</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private buildHandoverDocumentHtml(data: {
|
||
reference: string;
|
||
handedOverAt: Date;
|
||
bookingReference: string;
|
||
bookingStatus: string | null;
|
||
customerName: string | null;
|
||
serviceType: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
route: string | null;
|
||
scheduledDate: Date | null;
|
||
containerNumber: string | null;
|
||
bookingContainerSummary: string | null;
|
||
cargoDescription: string | null;
|
||
quantity: number;
|
||
weight: number;
|
||
bookingDeclaredWeight: number;
|
||
warehouse: string | null;
|
||
yard: string | null;
|
||
zone: string | null;
|
||
inventoryStatus: string | null;
|
||
inspectionStatus: string | null;
|
||
releaseOrderReference: string | null;
|
||
releaseDate: Date | null;
|
||
trainSchedule: string | null;
|
||
lastMileDeliveryAddress: string | null;
|
||
truckPlate?: string | null;
|
||
customerApproval: {
|
||
approvedAt: string;
|
||
signerDisplayName: string;
|
||
signatureImageUrl: string | null;
|
||
} | null;
|
||
/** The one global company stamp; null falls back to the drawn text seal. */
|
||
stampImageUrl?: string | null;
|
||
/** The one global company logo; null renders the plain text brand. */
|
||
logoImageUrl?: string | null;
|
||
}): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const fmt = (date: Date | string | null) => {
|
||
if (!date) return '-';
|
||
const parsed = date instanceof Date ? date : new Date(date);
|
||
if (Number.isNaN(parsed.getTime())) return '-';
|
||
return parsed.toLocaleString('en-GB', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
const rows = [
|
||
['Booking Reference', data.bookingReference],
|
||
['Customer / Consignee', data.customerName],
|
||
['Booking Status', data.bookingStatus],
|
||
['Service Type', data.serviceType],
|
||
['Freight Type', data.freightType],
|
||
['Trade Direction', data.tradeDirection],
|
||
['Route', data.route],
|
||
['Scheduled Date', fmt(data.scheduledDate)],
|
||
['Train Schedule', data.trainSchedule],
|
||
['Container Number', data.containerNumber],
|
||
['Booking Containers', data.bookingContainerSummary],
|
||
['Cargo / Goods Description', data.cargoDescription],
|
||
['Quantity', data.quantity],
|
||
['Inventory Weight', `${data.weight.toLocaleString()} t`],
|
||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||
['Warehouse', data.warehouse],
|
||
['Yard', data.yard],
|
||
['Zone', data.zone],
|
||
['Inventory Status', data.inventoryStatus],
|
||
['Inspection Status', data.inspectionStatus],
|
||
['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;
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Import Goods Handover Document</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
|
||
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
|
||
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
|
||
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
|
||
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
|
||
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
|
||
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
|
||
.signature-meta { font-size: 11px; color: #061323; }
|
||
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||
.seal span { position: relative; }
|
||
${sealImageCss()}
|
||
${logoImageCss()}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
${logoMarkup(data.logoImageUrl)}
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Import Goods Handover Document</h1>
|
||
<div class="subtitle">EDR to customer warehouse handover</div>
|
||
</div>
|
||
<div class="ref">
|
||
Document No.
|
||
<strong>${esc(data.reference)}</strong>
|
||
Handover: ${esc(fmt(data.handedOverAt))}
|
||
</div>
|
||
</div>
|
||
<div class="rule"></div>
|
||
<div class="notice">
|
||
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
|
||
inspection, release, and customer approval details for the goods being handed to the customer.
|
||
</div>
|
||
<div class="section-title">Handover Particulars</div>
|
||
<table>
|
||
<tbody>
|
||
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
<div class="section-title">Goods List</div>
|
||
<table>
|
||
<tbody>
|
||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} t`)}</td></tr>
|
||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="section-title">Handover Clause</div>
|
||
<div class="clause">
|
||
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
|
||
inspection status, and release records before final physical handover.
|
||
</div>
|
||
<div class="signatures">
|
||
<div class="line">Officer in charge name / signature / date</div>
|
||
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}</div>
|
||
<div class="line">
|
||
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
|
||
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
|
||
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private extractCustomerDeliveryApproval(notes?: string | null): {
|
||
approvedAt: string;
|
||
signerDisplayName: string;
|
||
signatureImageUrl: string;
|
||
} | null {
|
||
if (!notes) return null;
|
||
const line = notes
|
||
.split(/\r?\n/)
|
||
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||
if (!line) return null;
|
||
try {
|
||
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
|
||
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
|
||
return {
|
||
approvedAt: String(parsed.approvedAt),
|
||
signerDisplayName: String(parsed.signerDisplayName),
|
||
signatureImageUrl: String(parsed.signatureImageUrl),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
|
||
if (!notes?.trim()) return null;
|
||
const lines = notes
|
||
.split(/\r?\n/)
|
||
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||
return lines.join('\n').trim() || null;
|
||
}
|
||
|
||
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||
}
|
||
}
|
||
|
||
private async validateLocation(
|
||
manager: EntityManager,
|
||
dto: LocationRef,
|
||
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
|
||
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
|
||
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } });
|
||
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||
return { warehouse, yard, zone };
|
||
}
|
||
|
||
private appendNote(existing: string | null | undefined, note: string): string {
|
||
const trimmed = existing?.trim();
|
||
return trimmed ? `${trimmed}\n${note}` : note;
|
||
}
|
||
|
||
private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void {
|
||
if (!truckEntrance?.truckPlateNumber?.trim()) {
|
||
throw new BadRequestException('Truck plate number is required for entrance registration');
|
||
}
|
||
if (!truckEntrance.driverName?.trim()) {
|
||
throw new BadRequestException('Driver name is required for entrance registration');
|
||
}
|
||
if (!truckEntrance.driverPhone?.trim()) {
|
||
throw new BadRequestException('Driver phone is required for entrance registration');
|
||
}
|
||
if (truckEntrance.weighingRequired) {
|
||
if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
|
||
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
|
||
}
|
||
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
|
||
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
|
||
}
|
||
}
|
||
}
|
||
|
||
private mergeSystemTruckEntrance(
|
||
submitted: TruckEntranceDto,
|
||
booking: {
|
||
reference?: string | null;
|
||
customer?: string | null;
|
||
customerTin?: string | null;
|
||
customerPhone?: string | null;
|
||
containerNumber?: string | null;
|
||
containerQuantity?: number | string | null;
|
||
containerPackagingType?: string | null;
|
||
cargoDescription?: string | null;
|
||
weight?: number | string | null;
|
||
firstMileTruckPlateNumber?: string | null;
|
||
firstMileTrailerPlateNumber?: string | null;
|
||
firstMileDriverName?: string | null;
|
||
firstMileDriverPhone?: string | null;
|
||
firstMileDriverLicenseNumber?: string | null;
|
||
firstMileTruckType?: string | null;
|
||
customerTruckPlateNumber?: string | null;
|
||
customerTruckDriverName?: string | null;
|
||
customerTruckType?: string | null;
|
||
customerTruckContainerNumber?: string | null;
|
||
customerTruckAssignedAt?: string | null;
|
||
},
|
||
): TruckEntranceDto {
|
||
return {
|
||
...submitted,
|
||
ownerName: booking.customer?.trim() || submitted.ownerName,
|
||
edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
|
||
tin: booking.customerTin?.trim() || submitted.tin,
|
||
customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
|
||
assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber,
|
||
itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription,
|
||
packagingType: booking.containerPackagingType?.trim() || submitted.packagingType,
|
||
unitCount:
|
||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||
? Number(booking.containerQuantity)
|
||
: submitted.unitCount,
|
||
grossWeightKg: submitted.grossWeightKg,
|
||
truckPlateNumber:
|
||
booking.firstMileTruckPlateNumber?.trim() ||
|
||
booking.customerTruckPlateNumber?.trim() ||
|
||
submitted.truckPlateNumber,
|
||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||
driverName:
|
||
booking.firstMileDriverName?.trim() ||
|
||
booking.customerTruckDriverName?.trim() ||
|
||
submitted.driverName,
|
||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||
truckType:
|
||
booking.firstMileTruckType?.trim() ||
|
||
booking.customerTruckType?.trim() ||
|
||
submitted.truckType,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* EXPORT self-haul mirror of the customer truck lifecycle IMPORT already has:
|
||
* import stamps a truck's arrival on the SEPARATE gate action that comes
|
||
* later (release()'s arrival branch, when the customer's truck shows up to
|
||
* COLLECT already-warehoused goods). Export has no such separate step — the
|
||
* truck delivering cargo TO the warehouse arrives and is received in the
|
||
* same act, so receive()/bulkReceive() themselves are the arrival event.
|
||
* Matched by plate (not assignmentId — neither receive endpoint carries
|
||
* one), same as release()'s departure-branch self-haul UPDATE.
|
||
*/
|
||
private async markCustomerTruckArrived(
|
||
manager: EntityManager,
|
||
bookingId: string,
|
||
plateNumber: string | null | undefined,
|
||
): Promise<void> {
|
||
const plate = plateNumber?.trim();
|
||
if (!plate) return;
|
||
await manager.query(
|
||
`UPDATE freight.customer_truck_assignments a
|
||
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
|
||
WHERE a.booking_id = $1
|
||
AND UPPER(a.plate_number) = UPPER($2)
|
||
AND a.deleted_at IS NULL`,
|
||
[bookingId, plate],
|
||
);
|
||
}
|
||
|
||
private async getBookingTruckEntranceSource(
|
||
manager: EntityManager,
|
||
bookingId: string,
|
||
): Promise<{
|
||
reference?: string | null;
|
||
customer?: string | null;
|
||
customerTin?: string | null;
|
||
customerPhone?: string | null;
|
||
containerNumber?: string | null;
|
||
containerQuantity?: number | string | null;
|
||
containerPackagingType?: string | null;
|
||
cargoDescription?: string | null;
|
||
weight?: number | string | null;
|
||
firstMileTruckPlateNumber?: string | null;
|
||
firstMileTrailerPlateNumber?: string | null;
|
||
firstMileDriverName?: string | null;
|
||
firstMileDriverPhone?: string | null;
|
||
firstMileDriverLicenseNumber?: string | null;
|
||
firstMileTruckType?: string | null;
|
||
customerTruckPlateNumber?: string | null;
|
||
customerTruckDriverName?: string | null;
|
||
customerTruckType?: string | null;
|
||
customerTruckContainerNumber?: string | null;
|
||
}> {
|
||
const [booking] = await manager.query(
|
||
`SELECT b.reference AS "reference",
|
||
company.name AS "customer",
|
||
company.tin AS "customerTin",
|
||
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||
b.cargo_total_weight_vgm AS "weight",
|
||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||
bc.container_numbers AS "containerNumber",
|
||
bc.container_quantity AS "containerQuantity",
|
||
bc.container_packaging_type AS "containerPackagingType",
|
||
v.plate_number AS "firstMileTruckPlateNumber",
|
||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||
COALESCE(
|
||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||
v.assigned_driver_name
|
||
) AS "firstMileDriverName",
|
||
driver.phone_number AS "firstMileDriverPhone",
|
||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||
v.vehicle_type AS "firstMileTruckType",
|
||
-- Self-haul truck assigned via the portal — export delivering to
|
||
-- the warehouse or import collecting from it. Same pattern as
|
||
-- eligibleBookings/importQueueByStatuses: multi-truck self-haul
|
||
-- writes plates/drivers to customer_truck_assignments and leaves
|
||
-- the booking columns null, so read the assignments first and
|
||
-- keep the legacy column as the fallback for single-truck
|
||
-- bookings written before that table existed.
|
||
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||
FROM freight.customer_truck_assignments cta
|
||
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||
b.customer_truck_driver_name) AS "customerTruckDriverName",
|
||
b.customer_truck_type AS "customerTruckType",
|
||
b.customer_truck_container_number AS "customerTruckContainerNumber"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
${primaryContactUserJoin('company')}
|
||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||
SUM(booking_container.quantity)::int AS container_quantity,
|
||
CASE
|
||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||
ELSE 'OTHER_CONTAINER'
|
||
END AS container_packaging_type
|
||
FROM freight.booking_container booking_container
|
||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||
) bc ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT first_mile.vehicle_id
|
||
FROM freight.first_mile first_mile
|
||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||
ORDER BY first_mile.created_at DESC
|
||
LIMIT 1
|
||
) fm ON true
|
||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
return booking ?? {};
|
||
}
|
||
|
||
/**
|
||
* Tell the customer their export carriage acceptance sheet is ready to
|
||
* download from the portal. Export acceptance happens at the warehouse gate
|
||
* (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable
|
||
* as soon as the cargo is received, no wagon allocation required, so this
|
||
* fires right after receive, not at marshalling.
|
||
*/
|
||
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
||
await notifyCarriageAcceptanceReadyShared(
|
||
this.dataSource,
|
||
this.notifications,
|
||
this.inbox,
|
||
bookingId,
|
||
this.logger,
|
||
);
|
||
}
|
||
|
||
private async notifyOwnerInventoryReceived(params: {
|
||
phone?: string | null;
|
||
ownerName?: string | null;
|
||
bookingReference?: string | null;
|
||
grnNumber: string;
|
||
direction?: string | null;
|
||
warehouseId?: string | null;
|
||
}): Promise<void> {
|
||
const phone = params.phone?.trim();
|
||
if (!phone) return;
|
||
|
||
const ownerName = params.ownerName?.trim() || 'Customer';
|
||
const bookingReference = params.bookingReference?.trim();
|
||
const message =
|
||
`Dear ${ownerName}, your cargo has been received by EDR warehouse. ` +
|
||
(bookingReference ? `Booking: ${bookingReference}. ` : '') +
|
||
`GRN: ${params.grnNumber}. ` +
|
||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||
`Thank you.`;
|
||
|
||
try {
|
||
await this.notifications.directSend('sms', phone, message);
|
||
} catch (error) {
|
||
// Receiving inventory must not be rolled back because an SMS provider is unavailable.
|
||
this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||
private generateGrnNumber(
|
||
direction: string,
|
||
referenceId: string,
|
||
date: Date,
|
||
ownerName?: string | null,
|
||
): string {
|
||
return generateGrnNumber(direction, referenceId, date, ownerName);
|
||
}
|
||
|
||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||
let bookingReference = item.booking?.reference;
|
||
if (!bookingReference && item.bookingId) {
|
||
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
|
||
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||
[item.bookingId],
|
||
);
|
||
bookingReference = booking?.reference ?? undefined;
|
||
}
|
||
if (bookingReference) {
|
||
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
|
||
}
|
||
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
|
||
}
|
||
|
||
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
||
const hasExitInspection =
|
||
Boolean(dto.truckPlateNumber?.trim()) ||
|
||
Boolean(dto.trailerPlateNumber?.trim()) ||
|
||
Boolean(dto.driverName?.trim()) ||
|
||
Boolean(dto.driverLicense?.trim()) ||
|
||
Boolean(dto.driverPhone?.trim()) ||
|
||
Boolean(dto.truckType?.trim()) ||
|
||
Boolean(dto.containerNumber?.trim()) ||
|
||
dto.tareWeight !== undefined ||
|
||
dto.grossWeight !== undefined ||
|
||
dto.netWeight !== undefined ||
|
||
Boolean(dto.gateInTime) ||
|
||
Boolean(dto.gateOutTime);
|
||
|
||
if (!hasExitInspection) return null;
|
||
|
||
if (!dto.truckPlateNumber?.trim()) {
|
||
throw new BadRequestException('Truck plate number is required for exit inspection');
|
||
}
|
||
if (!dto.driverName?.trim()) {
|
||
throw new BadRequestException('Driver name is required for exit inspection');
|
||
}
|
||
// 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 = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
|
||
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
|
||
const computedNetWeight =
|
||
grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
|
||
const submittedNetWeight =
|
||
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
|
||
|
||
if (grossWeight != null && !dto.gateOutTime) {
|
||
throw new BadRequestException('Gate out time is required for truck exit');
|
||
}
|
||
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
|
||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||
}
|
||
}
|
||
if (
|
||
!weighingSkipped &&
|
||
(dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
|
||
grossWeight == null
|
||
) {
|
||
throw new BadRequestException('Gross weight is required for truck exit');
|
||
}
|
||
|
||
const rows = [
|
||
'[Exit Inspection]',
|
||
dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
|
||
dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
|
||
`Truck Plate: ${dto.truckPlateNumber.trim()}`,
|
||
dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
|
||
`Driver: ${dto.driverName.trim()}`,
|
||
dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
|
||
dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
|
||
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,
|
||
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||
// Skipped weighing still records a net — the cargo weight of the
|
||
// containers the truck is holding, resolved by the caller.
|
||
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
|
||
? null
|
||
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
|
||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||
];
|
||
|
||
return rows.filter(Boolean).join('\n');
|
||
}
|
||
|
||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
|
||
if (!inspection) return dto;
|
||
|
||
return {
|
||
...dto,
|
||
// The submitted plate wins: a legacy block may store a comma-joined list
|
||
// of plates, and the exit must be recorded against the ONE truck leaving.
|
||
truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
|
||
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Split notes into exit-inspection blocks (one per truck, in order) and
|
||
* everything else. A block ends at the first line that isn't one of the
|
||
* known inspection labels, so appended notes (delivery approval, handover
|
||
* marker) are preserved as "other" content instead of being swallowed by
|
||
* the block they happen to follow.
|
||
*/
|
||
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
|
||
const trimmed = notes?.trim();
|
||
if (!trimmed) return { others: [], blocks: [] };
|
||
const labelPattern =
|
||
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
|
||
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
|
||
const others: string[] = [];
|
||
const blocks: string[] = [];
|
||
if (parts[0]?.trim()) others.push(parts[0].trim());
|
||
for (const part of parts.slice(1)) {
|
||
const lines = part.split('\n');
|
||
const kept: string[] = [];
|
||
let i = 0;
|
||
while (i < lines.length && !lines[i].trim()) i += 1;
|
||
for (; i < lines.length; i += 1) {
|
||
const line = lines[i].trim();
|
||
if (!line || !labelPattern.test(line)) break;
|
||
kept.push(line);
|
||
}
|
||
if (kept.length) blocks.push(kept.join('\n'));
|
||
const tail = lines.slice(i).join('\n').trim();
|
||
if (tail) others.push(tail);
|
||
}
|
||
return { others, blocks };
|
||
}
|
||
|
||
/**
|
||
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
|
||
* legacy comma-joined list ("P1, P2") containing it.
|
||
*/
|
||
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
|
||
const plate = plateNumber?.trim().toUpperCase();
|
||
if (!plate) return false;
|
||
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
|
||
if (!stored) return false;
|
||
if (stored === plate) return true;
|
||
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
|
||
}
|
||
|
||
/**
|
||
* Replace THIS truck's inspection block (matched by plate), keeping every
|
||
* other truck's block untouched; append when the plate has no block yet.
|
||
* A single legacy block (comma-joined plates or plate-less caller) is
|
||
* replaced in place so old single-truck items keep their behaviour.
|
||
*/
|
||
private replaceExitInspectionNote(
|
||
notes: string | null | undefined,
|
||
exitInspectionNote: string | null,
|
||
plateNumber?: string | null,
|
||
): string | null {
|
||
const { others, blocks } = this.splitExitInspectionSections(notes);
|
||
if (exitInspectionNote) {
|
||
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
|
||
const index = plateNumber
|
||
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
|
||
: blocks.length - 1;
|
||
if (index >= 0) blocks[index] = content;
|
||
else blocks.push(content);
|
||
}
|
||
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
|
||
return sections.join('\n\n') || null;
|
||
}
|
||
|
||
/** Latest truck's inspection block — legacy summary for documents. */
|
||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||
const { blocks } = this.splitExitInspectionSections(notes);
|
||
return blocks.length ? blocks[blocks.length - 1] : null;
|
||
}
|
||
|
||
/**
|
||
* The inspection block for one truck. Falls back to a lone existing block so
|
||
* legacy single-truck items (saved before per-plate blocks) keep working.
|
||
*/
|
||
private extractExitInspectionForPlate(
|
||
notes: string | null | undefined,
|
||
plateNumber?: string | null,
|
||
): string | null {
|
||
const { blocks } = this.splitExitInspectionSections(notes);
|
||
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
|
||
if (match) return match;
|
||
return blocks.length === 1 ? blocks[0] : null;
|
||
}
|
||
|
||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||
return match?.[1]?.trim() || null;
|
||
}
|
||
|
||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
|
||
if (!value) return undefined;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : undefined;
|
||
}
|
||
|
||
private extractReceiveSummary(notes?: string | null): string | null {
|
||
if (!notes?.trim()) return null;
|
||
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
|
||
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
|
||
}
|
||
|
||
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
|
||
return [
|
||
HANDOVER_DOCUMENT_MARKER,
|
||
`Handover Reference: ${reference}`,
|
||
`Generated At: ${generatedAt.toISOString()}`,
|
||
].join('\n');
|
||
}
|
||
|
||
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
|
||
const trimmed = notes?.trim();
|
||
if (!trimmed) return handoverDocumentNote;
|
||
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||
if (index < 0) {
|
||
return `${trimmed}\n\n${handoverDocumentNote}`;
|
||
}
|
||
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
|
||
}
|
||
|
||
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
|
||
if (!notes) return null;
|
||
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
|
||
if (index < 0) return null;
|
||
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
|
||
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||
return match?.[1]?.trim() || null;
|
||
}
|
||
|
||
private buildReceiveNote(input: {
|
||
grnNumber: string;
|
||
direction?: string | null;
|
||
notes?: string | null;
|
||
truckEntrance?: TruckEntranceDto;
|
||
}): string {
|
||
const truck = input.truckEntrance;
|
||
const rows = [
|
||
`GRN Number: ${input.grnNumber}`,
|
||
input.direction ? `Direction: ${input.direction}` : null,
|
||
truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||
truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||
truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||
truck?.tin ? `TIN: ${truck.tin}` : null,
|
||
truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||
truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
|
||
truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||
truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||
truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||
truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
|
||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
|
||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||
truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
|
||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
|
||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||
truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||
truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||
truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
||
];
|
||
return rows.filter(Boolean).join('\n');
|
||
}
|
||
|
||
private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise<InventoryAllocationCriteria> {
|
||
const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null;
|
||
|
||
if (!item.bookingId) {
|
||
return {
|
||
freightType: fallbackFreightType,
|
||
requiresInspection: item.inspectionStatus !== 'PASSED',
|
||
};
|
||
}
|
||
|
||
const [row]: Array<{
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
cargoTypeCode: string | null;
|
||
containerStatus: string | null;
|
||
originCountry: string | null;
|
||
destinationCountry: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT b.freight_type AS "freightType",
|
||
b.trade_direction AS "tradeDirection",
|
||
cgt.code AS "cargoTypeCode",
|
||
COALESCE(selected_container.status, booking_container.status) AS "containerStatus",
|
||
oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN freight.containers selected_container
|
||
ON selected_container.id = $2 AND selected_container.deleted_at IS NULL
|
||
LEFT JOIN LATERAL (
|
||
SELECT c.status
|
||
FROM freight.containers c
|
||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||
ORDER BY c.created_at ASC
|
||
LIMIT 1
|
||
) booking_container ON true
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[item.bookingId, item.containerId],
|
||
);
|
||
|
||
if (!row) {
|
||
return {
|
||
freightType: fallbackFreightType,
|
||
requiresInspection: item.inspectionStatus !== 'PASSED',
|
||
};
|
||
}
|
||
|
||
const derivedDirection = deriveTradeDirection(
|
||
{ country: row.originCountry },
|
||
{ country: row.destinationCountry },
|
||
);
|
||
|
||
return {
|
||
freightType: row.freightType ?? fallbackFreightType,
|
||
tradeDirection: row.tradeDirection ?? derivedDirection,
|
||
cargoTypeCode: row.cargoTypeCode,
|
||
containerStatus: row.containerStatus,
|
||
requiresInspection: item.inspectionStatus !== 'PASSED',
|
||
};
|
||
}
|
||
|
||
private yardTypeFor(criteria: InventoryAllocationCriteria): string {
|
||
const freightType = criteria.freightType?.toUpperCase();
|
||
if (freightType === 'CONTAINER') return 'CONTAINER_YARD';
|
||
if (freightType === 'BULK') return 'BULK_YARD';
|
||
return 'GENERAL_CARGO_YARD';
|
||
}
|
||
|
||
private zoneTypeFor(criteria: InventoryAllocationCriteria): string {
|
||
const freightType = criteria.freightType?.toUpperCase();
|
||
if (freightType === 'CONTAINER') return 'CONTAINER_ZONE';
|
||
if (freightType === 'BULK') return 'BULK_ZONE';
|
||
return 'GENERAL_CARGO_ZONE';
|
||
}
|
||
|
||
private async pickCapacityBalancedStorageLocation(
|
||
item: WarehouseInventory,
|
||
criteria: InventoryAllocationCriteria,
|
||
): Promise<StorageAllocationLocation | null> {
|
||
const weight = Number(item.weight) || 0;
|
||
const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0;
|
||
const yardType = this.yardTypeFor(criteria);
|
||
const zoneType = this.zoneTypeFor(criteria);
|
||
|
||
const query = async (warehouseId: string | null) => {
|
||
const [row]: Array<{
|
||
warehouseId: string;
|
||
facilityId: string | null;
|
||
warehouseName: string | null;
|
||
yardId: string;
|
||
yardName: string | null;
|
||
yardCode: string | null;
|
||
zoneId: string;
|
||
zoneName: string | null;
|
||
zoneCode: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT wh.id AS "warehouseId",
|
||
wh.facility_id AS "facilityId",
|
||
wh.name AS "warehouseName",
|
||
yard.id AS "yardId",
|
||
yard.name AS "yardName",
|
||
yard.code AS "yardCode",
|
||
zone.id AS "zoneId",
|
||
zone.name AS "zoneName",
|
||
zone.code AS "zoneCode"
|
||
FROM freight.warehouses wh
|
||
JOIN freight.warehouse_yards yard
|
||
ON yard.warehouse_id = wh.id
|
||
AND yard.deleted_at IS NULL
|
||
AND yard.status = 'ACTIVE'
|
||
AND yard.is_active = true
|
||
JOIN freight.warehouse_zones zone
|
||
ON zone.yard_id = yard.id
|
||
AND zone.deleted_at IS NULL
|
||
AND zone.status = 'ACTIVE'
|
||
AND zone.is_active = true
|
||
WHERE wh.deleted_at IS NULL
|
||
AND wh.status = 'ACTIVE'
|
||
AND wh.is_active = true
|
||
AND ($1::uuid IS NULL OR wh.id = $1::uuid)
|
||
AND (COALESCE(yard.max_weight, yard.capacity_weight) IS NULL
|
||
OR yard.current_weight::numeric + $4::numeric <= COALESCE(yard.max_weight, yard.capacity_weight))
|
||
AND (COALESCE(zone.max_weight, zone.capacity_weight) IS NULL
|
||
OR zone.current_weight::numeric + $4::numeric <= COALESCE(zone.max_weight, zone.capacity_weight))
|
||
AND (yard.capacity_containers IS NULL
|
||
OR yard.current_containers + $5::int <= yard.capacity_containers)
|
||
AND (zone.capacity_containers IS NULL
|
||
OR zone.current_containers + $5::int <= zone.capacity_containers)
|
||
ORDER BY
|
||
CASE WHEN yard.type = $2 THEN 0 ELSE 1 END,
|
||
CASE WHEN zone.type = $3 THEN 0 ELSE 1 END,
|
||
(
|
||
CASE WHEN yard.capacity_weight IS NULL OR yard.capacity_weight = 0 THEN 0
|
||
ELSE yard.current_weight::numeric / yard.capacity_weight::numeric END
|
||
+
|
||
CASE WHEN yard.capacity_containers IS NULL OR yard.capacity_containers = 0 THEN 0
|
||
ELSE yard.current_containers::numeric / yard.capacity_containers::numeric END
|
||
+
|
||
CASE WHEN zone.capacity_weight IS NULL OR zone.capacity_weight = 0 THEN 0
|
||
ELSE zone.current_weight::numeric / zone.capacity_weight::numeric END
|
||
+
|
||
CASE WHEN zone.capacity_containers IS NULL OR zone.capacity_containers = 0 THEN 0
|
||
ELSE zone.current_containers::numeric / zone.capacity_containers::numeric END
|
||
) ASC,
|
||
yard.code ASC,
|
||
zone.code ASC
|
||
LIMIT 1`,
|
||
[warehouseId, yardType, zoneType, weight, containerCount],
|
||
);
|
||
return row;
|
||
};
|
||
|
||
const row = (await query(item.warehouseId)) ?? (await query(null));
|
||
if (!row) return null;
|
||
|
||
return {
|
||
warehouseId: row.warehouseId,
|
||
facilityId: row.facilityId,
|
||
yardId: row.yardId,
|
||
zoneId: row.zoneId,
|
||
rule: null,
|
||
path: [row.warehouseName, row.yardCode ?? row.yardName, row.zoneCode ?? row.zoneName]
|
||
.filter(Boolean)
|
||
.join(' -> '),
|
||
};
|
||
}
|
||
|
||
private async getBookingStatus(bookingId: string): Promise<string | null> {
|
||
const [row]: Array<{ status: string | null }> = await this.dataSource.query(
|
||
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||
[bookingId],
|
||
);
|
||
return row?.status ?? null;
|
||
}
|
||
|
||
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
|
||
const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[];
|
||
if (bookingIds.length === 0) return;
|
||
|
||
const rows: BookingSummaryRow[] = await this.dataSource.query(
|
||
`SELECT b.id, b.reference, b.status, company.name AS customer
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||
WHERE b.id = ANY($1) AND b.deleted_at IS NULL`,
|
||
[bookingIds],
|
||
);
|
||
const summaries = new Map(rows.map((row) => [row.id, row]));
|
||
|
||
items.forEach((item) => {
|
||
const summary = item.bookingId ? summaries.get(item.bookingId) : undefined;
|
||
if (!summary) return;
|
||
Object.assign(item, {
|
||
bookingReference: summary.reference,
|
||
bookingStatus: summary.status,
|
||
customerName: summary.customer,
|
||
});
|
||
});
|
||
}
|
||
|
||
/** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */
|
||
private async getBookingDirection(bookingId: string): Promise<string | null> {
|
||
const rows = await this.dataSource.query(
|
||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
if (!rows?.[0]) return null;
|
||
return deriveTradeDirection(
|
||
{ country: rows[0].originCountry },
|
||
{ country: rows[0].destinationCountry },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Export cargo is received into the warehouse to wait for its train, and it is
|
||
* received only once the booking is paid — receiving an unpaid export booking
|
||
* would start storage and mint a GRN against cargo the customer has not settled.
|
||
*
|
||
* Export only: import cargo arrives OFF a train and its receive is the unload,
|
||
* so gating that on payment would strand cargo already at the yard.
|
||
*/
|
||
private async assertExportBookingPaid(
|
||
bookingId: string | null | undefined,
|
||
direction: string | null,
|
||
): Promise<void> {
|
||
if (!bookingId || direction !== 'EXPORT') return;
|
||
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
|
||
`SELECT payment_status AS "paymentStatus"
|
||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
|
||
throw new BadRequestException(
|
||
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
|
||
);
|
||
}
|
||
}
|
||
|
||
private assertCapacity(
|
||
label: string,
|
||
node: LocationNode,
|
||
weightAdd: number,
|
||
volumeAdd: number,
|
||
containerAdd: number,
|
||
): void {
|
||
const maxWeight = node.maxWeight ?? node.capacityWeight;
|
||
if (maxWeight != null) {
|
||
const projected = Number(node.currentWeight) + weightAdd;
|
||
if (projected > Number(maxWeight)) {
|
||
throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
|
||
}
|
||
}
|
||
if (node.maxVolume != null && volumeAdd > 0) {
|
||
const projected = Number(node.currentVolume) + volumeAdd;
|
||
if (projected > Number(node.maxVolume)) {
|
||
throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
|
||
}
|
||
}
|
||
if (node.capacityContainers != null && containerAdd > 0) {
|
||
const projected = Number(node.currentContainers) + containerAdd;
|
||
if (projected > Number(node.capacityContainers)) {
|
||
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async applyCapacityDelta(
|
||
manager: EntityManager,
|
||
location: LocationRef,
|
||
weightAdd: number,
|
||
volumeAdd: number,
|
||
containerAdd: number,
|
||
): Promise<void> {
|
||
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
|
||
[Warehouse, location.warehouseId],
|
||
[WarehouseYard, location.yardId],
|
||
[WarehouseZone, location.zoneId],
|
||
];
|
||
|
||
for (const [entity, id] of targets) {
|
||
if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd);
|
||
if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd));
|
||
if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd);
|
||
if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd));
|
||
if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd);
|
||
if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd));
|
||
}
|
||
}
|
||
}
|