Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
2026-06-30 07:19:12 +03:00

3915 lines
164 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Cargo } from '../cargoes/entities/cargoes.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 { 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';
/** 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 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;
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;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: 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 }[];
}
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;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: 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,
) {}
/**
* 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 } } : {}),
};
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 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 warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
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
ORDER BY wh.created_at ASC
LIMIT 1`,
);
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;
}
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(),
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 } });
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) 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,
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,
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",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
bc.container_numbers AS "containerNumber",
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"
FROM freight.bookings b
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 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 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: [] };
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
});
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",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) 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"
FROM freight.bookings b
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.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,
}),
);
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,
);
await this.notifyOwnerInventoryReceived({
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
}
});
return result;
}
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
};
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
if (bookingStatus !== 'PAID') { skip('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: 'Bulk loaded (passed export)',
performedBy,
},
manager,
);
});
result.loadedCount += 1;
result.results.push({ inventoryId: item.id, status: 'LOADED' });
}
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');
}
/** 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 b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
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",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
WHERE inv.deleted_at IS NULL
AND inv.status = ANY($1)
ORDER BY inv.created_at DESC`,
[statuses],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/**
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses([
'UNLOADED',
'DESTINATION_INSPECTION',
'UNDER_INSPECTION',
'ARRIVED_AT_WAREHOUSE',
'STORED',
'READY_FOR_PICKUP',
'DISPATCHED',
'DELIVERED',
]);
}
/**
* Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
* awaiting customer pickup / last mile / store / dispatch. Read-only.
*/
importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['READY_FOR_PICKUP']);
}
/**
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
* LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT.
*/
async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise<BulkDispatchResult> {
const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] };
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = await this.inventoryRepository.findById(inventoryId);
if (!item) { skip('Inventory not found'); continue; }
if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
try {
await this.dispatch(inventoryId, performedBy);
result.dispatchedCount += 1;
result.results.push({ inventoryId, status: 'DISPATCHED' });
} catch (error) {
skip(error instanceof Error ? error.message : String(error));
}
}
return result;
}
/** Booking statuses that must never be unloaded into warehouse inventory. */
private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED'];
private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [
'LOADED',
'DISPATCHED',
'IN_TRANSIT',
'ARRIVED_AT_DJIBOUTI',
'ARRIVED_AT_PORT',
'ARRIVED_AT_DESTINATION',
];
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
const normalized = (value ?? '').toUpperCase();
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
normalized.includes(token),
);
}
/**
* 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,
): 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 fallback = await this.pickDefaultLocation();
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];
// 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, {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: existing.id,
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 = 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',
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 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"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (!booking?.reference || !booking.lastMileDeliveryAddress) 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);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `GRN ${grnNumber}: received ${weight}kg 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): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'STORED');
const criteria = await this.getInventoryAllocationCriteria(item);
const ruleLocation = await this.allocation.resolveLocation(criteria);
const location = 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);
}
await manager.getRepository(WarehouseInventory).update(id, {
status: 'STORED',
storedAt: new Date(),
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
notes: this.appendNote(
locked.notes,
ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_STORED',
inventoryId: id,
warehouseId: location.warehouseId,
description: ruleLocation?.rule
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
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,
});
}
/** 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})`,
);
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
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,
);
});
return this.findById(id);
}
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');
}
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),
});
return {
filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(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`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
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');
}
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
notes: this.appendNote(existingNotes, approvalNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
},
manager,
);
});
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
};
}
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`);
}
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`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
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');
}
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,
);
});
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,
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))];
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: 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')
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
.leftJoin(
'freight.containers',
'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(
'freight.cargoes',
'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('freight.cargo_types', '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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : 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;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
...(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : 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()} kg`)}</td></tr>
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : 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.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
throw new BadRequestException('Entrance tare weight is required for entrance registration');
}
}
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;
},
): 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:
booking.weight !== undefined && booking.weight !== null
? Number(booking.weight)
: submitted.grossWeightKg,
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
truckType: booking.firstMileTruckType?.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",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) 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
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)}`);
}
}
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
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');
}
if (dto.tareWeight === undefined) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == 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 ((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,
`Tare Weight: ${tareWeight} kg`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
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 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)} kg` : null,
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : 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)} kg` : null,
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : 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));
}
}
}