mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
3915 lines
164 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
const rows: Array<[string, unknown]> = [
|
|
['Booking Reference', data.bookingReference],
|
|
['Customer / Consignee', data.customerName],
|
|
['Customer TIN', data.customerTin],
|
|
['Booking Status', data.bookingStatus],
|
|
['Service Type', data.serviceType],
|
|
['Freight Type', data.freightType],
|
|
['Trade Direction', data.tradeDirection],
|
|
['Route', data.route],
|
|
['Container Number', data.containerNumber],
|
|
['Booking Containers', data.bookingContainerSummary],
|
|
['Cargo / Goods Description', data.cargoDescription],
|
|
['Quantity', data.quantity],
|
|
['Received Weight', `${data.weight.toLocaleString()} 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
const issuedAt = data.issuedAt.toLocaleString('en-GB', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
const rows = [
|
|
['Booking Reference', data.bookingReference],
|
|
['Customer / Consignee', data.customerName],
|
|
['Booking Status', data.bookingStatus],
|
|
['Freight Type', data.freightType],
|
|
['Trade Direction', data.tradeDirection],
|
|
['Container Number', data.containerNumber],
|
|
['Cargo / Goods Description', data.cargoDescription],
|
|
['Quantity', data.quantity],
|
|
['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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
const fmt = (date: Date | string | null) => {
|
|
if (!date) return '-';
|
|
const parsed = date instanceof Date ? date : new Date(date);
|
|
if (Number.isNaN(parsed.getTime())) return '-';
|
|
return parsed.toLocaleString('en-GB', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
const rows = [
|
|
['Booking Reference', data.bookingReference],
|
|
['Customer / Consignee', data.customerName],
|
|
['Booking Status', data.bookingStatus],
|
|
['Service Type', data.serviceType],
|
|
['Freight Type', data.freightType],
|
|
['Trade Direction', data.tradeDirection],
|
|
['Route', data.route],
|
|
['Scheduled Date', fmt(data.scheduledDate)],
|
|
['Train Schedule', data.trainSchedule],
|
|
['Container Number', data.containerNumber],
|
|
['Booking Containers', data.bookingContainerSummary],
|
|
['Cargo / Goods Description', data.cargoDescription],
|
|
['Quantity', data.quantity],
|
|
['Inventory Weight', `${data.weight.toLocaleString()} 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));
|
|
}
|
|
}
|
|
}
|