mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
2650 lines
103 KiB
TypeScript
2650 lines
103 KiB
TypeScript
import { BadRequestException, Injectable, 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 { ContractPdfService } from '../../contracts/contract-pdf.service';
|
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
|
import { BulkReceiveDto } 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';
|
|
|
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
|
|
|
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;
|
|
direction: string;
|
|
origin: string | null;
|
|
destination: string | null;
|
|
freightType: string | null;
|
|
cargo: string | null;
|
|
weight: string | null;
|
|
paymentStatus: string;
|
|
status: string;
|
|
}
|
|
|
|
export interface BulkReceiveResult {
|
|
receivedCount: number;
|
|
skippedCount: number;
|
|
results: { bookingId: string; status: string; inventoryId?: 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;
|
|
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;
|
|
trainSchedule: string | null;
|
|
inspectionStatus: string | null;
|
|
pickupOption: string;
|
|
lastMileRequested: boolean;
|
|
currentStatus: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class WarehouseInventoryService {
|
|
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 pdfService: ContractPdfService,
|
|
private readonly interchangeDocuments: InterchangeDocumentsService,
|
|
private readonly lastMileService: LastMileService,
|
|
) {}
|
|
|
|
/**
|
|
* 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 = await this.findById(id);
|
|
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();
|
|
await this.inventoryRepository.update(id, {
|
|
gateClearedAt: now,
|
|
releaseDate: item.releaseDate ?? 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;
|
|
|
|
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",
|
|
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",
|
|
b.cargo_total_weight_vgm AS "weight",
|
|
b.payment_status AS "paymentStatus",
|
|
b.status AS "status"
|
|
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.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
|
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.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
|
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 (!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;
|
|
}
|
|
|
|
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
|
if (existing) { skip('Already received'); continue; }
|
|
|
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
|
manager.getRepository(WarehouseInventory).create({
|
|
warehouseId: dto.warehouseId,
|
|
yardId: dto.yardId,
|
|
zoneId: dto.zoneId,
|
|
bookingId,
|
|
quantity: 1,
|
|
weight: Number(booking.weight) || 0,
|
|
status: 'RECEIVED',
|
|
arrivedAt: new Date(),
|
|
notes: `Bulk received (${dto.direction})`,
|
|
}),
|
|
);
|
|
|
|
await this.activityLog.record(
|
|
{
|
|
activityType: 'INVENTORY_RECEIVED',
|
|
inventoryId: saved.id,
|
|
warehouseId: dto.warehouseId,
|
|
description: `Bulk received ${dto.direction} booking`,
|
|
performedBy: dto.performedBy,
|
|
},
|
|
manager,
|
|
);
|
|
|
|
result.receivedCount += 1;
|
|
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
|
|
}
|
|
});
|
|
|
|
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",
|
|
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 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",
|
|
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",
|
|
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']);
|
|
}
|
|
|
|
/**
|
|
* 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 = [
|
|
'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.name 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 || !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) {
|
|
const 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,
|
|
remarks: 'Generated after export unloading at Djibouti Port',
|
|
});
|
|
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 weight = Number(dto.weight) || 0;
|
|
const volume = Number(dto.volume) || 0;
|
|
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
|
|
|
const id = await this.dataSource.transaction(async (manager) => {
|
|
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
|
|
|
if (dto.bookingId) {
|
|
await this.assertBookingExists(manager, dto.bookingId);
|
|
}
|
|
|
|
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 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: Number(dto.quantity) || 0,
|
|
weight,
|
|
volume: dto.volume ?? null,
|
|
status: 'RECEIVED',
|
|
arrivedAt: now,
|
|
notes: dto.notes?.trim() ?? null,
|
|
}),
|
|
);
|
|
|
|
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
|
|
|
await this.activityLog.record(
|
|
{
|
|
activityType: 'INVENTORY_RECEIVED',
|
|
inventoryId: saved.id,
|
|
warehouseId: dto.warehouseId,
|
|
description: `Received ${weight}kg at warehouse location`,
|
|
performedBy: dto.performedBy,
|
|
},
|
|
manager,
|
|
);
|
|
|
|
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 releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
|
const reference = dto.reference?.trim() || null;
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(WarehouseInventory).update(id, {
|
|
releaseDate,
|
|
releaseOrderReference: reference,
|
|
});
|
|
await this.activityLog.record(
|
|
{
|
|
activityType: 'INVENTORY_RELEASED',
|
|
inventoryId: id,
|
|
warehouseId: item.warehouseId,
|
|
description: reference
|
|
? `Release order ${reference} sent to customer`
|
|
: 'Release order sent to customer',
|
|
performedBy: dto.performedBy,
|
|
},
|
|
manager,
|
|
);
|
|
});
|
|
|
|
return this.findById(id);
|
|
}
|
|
|
|
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
|
const item = await this.findById(id);
|
|
if (!item.releaseDate) {
|
|
throw new BadRequestException('A release order must be issued before downloading the exit paper');
|
|
}
|
|
|
|
const [row] = await this.dataSource.query(
|
|
`SELECT inv.id,
|
|
inv.release_order_reference AS "releaseOrderReference",
|
|
inv.release_date AS "releaseDate",
|
|
inv.quantity,
|
|
inv.weight,
|
|
inv.status,
|
|
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",
|
|
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 (
|
|
(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)
|
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[id],
|
|
);
|
|
|
|
const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`;
|
|
const bookingReference = row?.bookingReference || item.bookingId || 'N/A';
|
|
const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date();
|
|
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 ?? item.quantity ?? 0),
|
|
weight: Number(row?.weight ?? item.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 ?? item.status,
|
|
});
|
|
|
|
return {
|
|
filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
|
buffer: await this.pdfService.htmlToPdfBuffer(html),
|
|
};
|
|
}
|
|
|
|
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
|
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 (!LOADABLE_WAGON_STATUSES.includes(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 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;
|
|
}): 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', data.customerName],
|
|
['Booking status', data.bookingStatus],
|
|
['Freight type', data.freightType],
|
|
['Trade direction', data.tradeDirection],
|
|
['Container number', data.containerNumber],
|
|
['Cargo / goods', data.cargoDescription],
|
|
['Quantity', data.quantity],
|
|
['Weight', `${data.weight.toLocaleString()} kg`],
|
|
['Warehouse', data.warehouse],
|
|
['Yard', data.yard],
|
|
['Zone', data.zone],
|
|
['Inventory status', data.inventoryStatus],
|
|
];
|
|
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Warehouse Release Exit Paper</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
|
.doc { padding: 18px 8px; }
|
|
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 18px; }
|
|
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
|
h1 { margin: 8px 0 0; font-size: 30px; }
|
|
.ref { text-align: right; font-size: 13px; color: #475569; }
|
|
.ref strong { display: block; color: #0f172a; font-size: 18px; margin-top: 6px; }
|
|
.notice { margin: 22px 0; padding: 14px 16px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 8px; font-weight: 700; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
th { width: 32%; text-align: left; color: #475569; background: #f8fafc; }
|
|
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; font-size: 13px; }
|
|
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 42px; }
|
|
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
|
.footer { margin-top: 28px; font-size: 11px; color: #64748b; line-height: 1.5; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="doc">
|
|
<div class="top">
|
|
<div>
|
|
<div class="brand">EDR Warehouse Operations</div>
|
|
<h1>Warehouse Release / Exit Paper</h1>
|
|
</div>
|
|
<div class="ref">
|
|
Release reference
|
|
<strong>${esc(data.reference)}</strong>
|
|
Issued: ${esc(issuedAt)}
|
|
</div>
|
|
</div>
|
|
<div class="notice">
|
|
This document authorizes the listed booking/goods to leave the warehouse after release checks.
|
|
</div>
|
|
<table>
|
|
<tbody>
|
|
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
<div class="signatures">
|
|
<div class="line">Warehouse officer name / signature / date</div>
|
|
<div class="line">Customer or driver name / signature / date</div>
|
|
</div>
|
|
<div class="footer">
|
|
Present this release paper at the warehouse gate. Gate staff should verify booking reference,
|
|
customer/driver identity, cargo details, and any unpaid blocking fees before exit.
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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 async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
|
const rows = await manager.query(
|
|
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
|
[bookingId],
|
|
);
|
|
if (!rows || rows.length === 0) {
|
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
|
}
|
|
}
|
|
|
|
private appendNote(existing: string | null | undefined, note: string): string {
|
|
const trimmed = existing?.trim();
|
|
return trimmed ? `${trimmed}\n${note}` : note;
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
}
|