mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Every facility raises a GRN — the goods changed hands, whether or not anyone stores them. What differs is what happens next: Indode has a warehouse, so cargo left there goes through the existing warehouse flow and accrues storage and demurrage; Sebeta, Modjo, Adama and Dire Dawa only move cargo between train and truck, so the handling event and its GRN are the whole record. facility_handling_events carries that record because warehouse_inventory cannot: its warehouse/yard/zone are NOT NULL, so a facility with equipment but no warehouse could never have a row there. inventory_id links the storage record when the facility does keep the cargo, which is what ties an Indode handover to its demurrage. generateGrnNumber moves to common/grn.util.ts so a GRN raised at a facility is indistinguishable from one raised in a warehouse — the two live in different tables, and a second generator would let the formats drift. Recording is best-effort: the cargo moved regardless, so paperwork must never fail the journey. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5574 lines
238 KiB
TypeScript
5574 lines
238 KiB
TypeScript
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } 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 { NotificationsService } from '../notifications/notifications.service';
|
||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||
import {
|
||
companyNotifyPhoneExpr,
|
||
primaryContactUserJoin,
|
||
} from '../notifications/resolve-company-phone.util';
|
||
import { SignaturesService } from '../signatures/signatures.service';
|
||
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]';
|
||
|
||
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;
|
||
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,
|
||
) {}
|
||
|
||
/**
|
||
* 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)
|
||
*/
|
||
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",
|
||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||
WHERE deleted_at IS NULL
|
||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||
);
|
||
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 = [
|
||
'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 = {
|
||
...(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 } } : {}),
|
||
};
|
||
|
||
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' });
|
||
}
|
||
|
||
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;
|
||
weight: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
cargoTypeCode: string | null;
|
||
}[] = await this.dataSource.query(
|
||
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
|
||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||
cgt.code AS "cargoTypeCode"
|
||
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
|
||
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()) }
|
||
: {}),
|
||
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 } });
|
||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||
// a train without one. Import GRN handling is left untouched.
|
||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||
`SELECT trade_direction AS "tradeDirection"
|
||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||
|
||
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,
|
||
// Export only, and keep an already-issued GRN rather than reissuing.
|
||
...(isExport && !existing[0].grnNumber
|
||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||
: {}),
|
||
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,
|
||
...(isExport
|
||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||
: {}),
|
||
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",
|
||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||
OR COALESCE(st.includes_last_mile, false)) 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
|
||
OR COALESCE(st.includes_first_mile, false)) 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",
|
||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||
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.service_types st ON st.id = b.service_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
|
||
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",
|
||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||
OR COALESCE(st.includes_first_mile, false)) 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",
|
||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||
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
|
||
OR COALESCE(st.includes_last_mile, false)) 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.service_types st ON st.id = b.service_type_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);
|
||
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,
|
||
});
|
||
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: Number(booking.weight) || 0,
|
||
grnNumber,
|
||
status: 'RECEIVED',
|
||
arrivedAt: now,
|
||
notes: receiveNote,
|
||
}),
|
||
);
|
||
|
||
// 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],
|
||
);
|
||
|
||
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);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
|
||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||
private async exportInventoryByStatus(
|
||
status: WarehouseInventoryStatus,
|
||
requireInspectionPassed = false,
|
||
): 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
|
||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
||
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 that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
|
||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||
}
|
||
|
||
/** 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",
|
||
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||
OR COALESCE(st.includes_last_mile, false)
|
||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||
b.customer_truck_plate_number AS "customerTruckPlateNumber",
|
||
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",
|
||
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.service_types st ON st.id = b.service_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),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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, 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;
|
||
weight: string | null;
|
||
freightType: string | null;
|
||
tradeDirection: string | null;
|
||
cargoTypeCode: string | null;
|
||
}[] = await this.dataSource.query(
|
||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||
cgt.code AS "cargoTypeCode"
|
||
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
|
||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||
[scheduleId],
|
||
);
|
||
|
||
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,
|
||
}
|
||
: {}),
|
||
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) }),
|
||
});
|
||
await this.activityLog.record({
|
||
activityType: 'INVENTORY_UNLOADED',
|
||
inventoryId: existing.id,
|
||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||
description: 'Unloaded from arrived import train',
|
||
performedBy,
|
||
});
|
||
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),
|
||
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,
|
||
});
|
||
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) {
|
||
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,
|
||
};
|
||
}
|
||
|
||
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",
|
||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[bookingId],
|
||
);
|
||
const hasLastMile =
|
||
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
|
||
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;
|
||
|
||
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);
|
||
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],
|
||
);
|
||
}
|
||
|
||
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;
|
||
});
|
||
|
||
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');
|
||
}
|
||
if (item.inspectionStatus !== 'PASSED') {
|
||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
||
}
|
||
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;
|
||
const [row]: Array<{ ok: number }> = await runner.query(
|
||
`SELECT 1 AS ok
|
||
FROM freight.bookings b
|
||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||
AND (b.customer_truck_assigned_at IS NOT NULL
|
||
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
|
||
AND COALESCE(st.includes_last_mile, false) = false))`,
|
||
[bookingId],
|
||
);
|
||
return Boolean(row);
|
||
}
|
||
|
||
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
||
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
||
const item = await this.findById(id);
|
||
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',
|
||
);
|
||
}
|
||
|
||
// 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;
|
||
const reference = isTruckLeaving
|
||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||
const exitInspectionDto = isTruckLeaving
|
||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
||
: dto;
|
||
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);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(WarehouseInventory).update(id, {
|
||
releaseDate,
|
||
releaseOrderReference: reference,
|
||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||
});
|
||
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).
|
||
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`,
|
||
[
|
||
item.bookingId,
|
||
dto.truckPlateNumber.trim(),
|
||
dto.gateOutTime ?? null,
|
||
grossTons,
|
||
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);
|
||
}
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
/**
|
||
* 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}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
if (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;
|
||
}
|
||
|
||
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,
|
||
bookingReference,
|
||
bookingStatus: row?.bookingStatus ?? null,
|
||
customerName: row?.customerName ?? null,
|
||
freightType: row?.freightType ?? null,
|
||
tradeDirection: row?.tradeDirection ?? null,
|
||
containerNumber: 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,
|
||
// Prefer the weighed gross captured on departure; fall back to the summed
|
||
// container VGM when the truck hasn't been weighed yet.
|
||
truckWeightKg: truck
|
||
? Number(truck.grossWeightKg ?? 0) > 0
|
||
? Number(truck.grossWeightKg)
|
||
: Number(truck.truckWeightTons ?? 0) * 1000
|
||
: 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;
|
||
}>
|
||
> {
|
||
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;
|
||
}> = 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
|
||
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,
|
||
handoverSigned,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||
* container number. Drives the truck-leaving exit weighing: the selected
|
||
* containers' total cargo weight must match (gross − tare).
|
||
*/
|
||
async bookingContainerWeights(
|
||
bookingId: string,
|
||
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||
await this.dataSource.query(
|
||
`SELECT bcu.container_number AS "containerNumber",
|
||
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 }> =
|
||
await this.dataSource.query(
|
||
`SELECT c.container_number AS "containerNumber",
|
||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||
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],
|
||
);
|
||
|
||
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'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
inv.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 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,
|
||
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 approval = {
|
||
approvedAt: approvedAt.toISOString(),
|
||
signerDisplayName: name,
|
||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||
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);
|
||
|
||
return {
|
||
bookingId,
|
||
inventoryId: item.id,
|
||
approvedAt: approval.approvedAt,
|
||
signerDisplayName: name,
|
||
};
|
||
}
|
||
|
||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||
async handoverDocumentForBooking(bookingId: 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}`);
|
||
}
|
||
return this.handoverDocument(inv.id);
|
||
}
|
||
|
||
/** 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): Promise<{ filename: string; buffer: Buffer }> {
|
||
const [row] = await this.dataSource.query(
|
||
`SELECT inv.id,
|
||
inv.booking_id AS "bookingId",
|
||
inv.quantity,
|
||
inv.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 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 =
|
||
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;
|
||
if (!generatedAtValue) {
|
||
await this.inventoryRepository.update(id, {
|
||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||
});
|
||
}
|
||
|
||
const html = this.buildHandoverDocumentHtml({
|
||
reference,
|
||
handedOverAt,
|
||
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,
|
||
customerApproval: 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 [left]: Array<{ n: string }> = await this.dataSource.query(
|
||
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
|
||
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
|
||
[item.bookingId],
|
||
);
|
||
if (Number(left?.n ?? 0) === 0) {
|
||
throw new BadRequestException('Deliver is available only after the customer truck has left');
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
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;
|
||
},
|
||
): 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(),
|
||
});
|
||
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;
|
||
}): 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],
|
||
['Customer / Consignee', 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; }
|
||
.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>
|
||
<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;
|
||
truckWeightKg?: number | 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.truckWeightKg
|
||
? data.truckWeightKg
|
||
: 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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="doc">
|
||
<div class="top">
|
||
<div>
|
||
<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="seal"><span>EDR<br />Warehouse<br />Cleared</span></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;
|
||
customerApproval: {
|
||
approvedAt: string;
|
||
signerDisplayName: string;
|
||
signatureImageUrl: 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],
|
||
];
|
||
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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
<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="seal"><span>EDR<br />Warehouse<br />Handover</span></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,
|
||
};
|
||
}
|
||
|
||
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;
|
||
}> {
|
||
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"
|
||
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 ?? {};
|
||
}
|
||
|
||
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): string {
|
||
return generateGrnNumber(direction, referenceId, date);
|
||
}
|
||
|
||
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`,
|
||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} 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.extractExitInspectionNote(notes);
|
||
if (!inspection) return dto;
|
||
|
||
return {
|
||
...dto,
|
||
truckPlateNumber: 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,
|
||
};
|
||
}
|
||
|
||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||
const trimmed = notes?.trim();
|
||
if (!exitInspectionNote) return trimmed || null;
|
||
if (!trimmed) return exitInspectionNote;
|
||
|
||
const marker = '[Exit Inspection]';
|
||
const index = trimmed.lastIndexOf(marker);
|
||
if (index < 0) {
|
||
return `${trimmed}\n\n${exitInspectionNote}`;
|
||
}
|
||
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
||
}
|
||
|
||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||
if (!notes) return null;
|
||
const marker = '[Exit Inspection]';
|
||
const index = notes.lastIndexOf(marker);
|
||
if (index < 0) return null;
|
||
return notes.slice(index + marker.length).trim() || 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 },
|
||
);
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
}
|