|
|
|
|
@@ -3,6 +3,8 @@ import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrE
|
|
|
|
|
|
|
|
|
|
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 { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
|
|
|
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
|
|
|
|
@@ -46,6 +48,103 @@ export interface InventoryInquiryResult {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class WarehouseInventoryService {
|
|
|
|
|
constructor(
|
|
|
|
|
@@ -386,7 +485,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
|
|
|
|
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
|
|
|
|
|
|
|
|
|
await this.activityLog.record(
|
|
|
|
|
{
|
|
|
|
|
@@ -405,7 +504,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async move(id: string, dto: MoveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
|
|
|
|
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 },
|
|
|
|
|
@@ -428,12 +527,12 @@ export class WarehouseInventoryService {
|
|
|
|
|
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
|
|
|
|
|
|
|
|
|
if (item.warehouseId !== dto.warehouseId) {
|
|
|
|
|
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
|
|
|
|
|
this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount);
|
|
|
|
|
}
|
|
|
|
|
if (item.yardId !== dto.yardId) {
|
|
|
|
|
this.assertCapacity('Yard', yard, weight, containerCount);
|
|
|
|
|
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
|
|
|
|
|
}
|
|
|
|
|
this.assertCapacity('Zone', zone, weight, containerCount);
|
|
|
|
|
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
|
|
|
|
|
|
|
|
|
|
await this.applyCapacityDelta(
|
|
|
|
|
manager,
|
|
|
|
|
@@ -443,10 +542,11 @@ export class WarehouseInventoryService {
|
|
|
|
|
zoneId: item.zoneId,
|
|
|
|
|
},
|
|
|
|
|
-weight,
|
|
|
|
|
-(Number(item.volume) || 0),
|
|
|
|
|
-containerCount,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.applyCapacityDelta(manager, dto, weight, containerCount);
|
|
|
|
|
await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount);
|
|
|
|
|
|
|
|
|
|
item.warehouseId = dto.warehouseId;
|
|
|
|
|
item.yardId = dto.yardId;
|
|
|
|
|
@@ -597,34 +697,6 @@ export class WarehouseInventoryService {
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async store(id: string): Promise<WarehouseInventory> {
|
|
|
|
|
const item = await this.findById(id);
|
|
|
|
|
|
|
|
|
|
if (item.status !== 'RECEIVED' && item.status !== 'ARRIVED_AT_WAREHOUSE') {
|
|
|
|
|
throw new BadRequestException(`Only RECEIVED inventory can be stored (current: ${item.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.inventoryRepository.update(id, { status: 'STORED' });
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async reserve(id: string, dto: { bookingId?: string }): Promise<WarehouseInventory> {
|
|
|
|
|
const item = await this.findById(id);
|
|
|
|
|
|
|
|
|
|
if (item.status !== 'STORED') {
|
|
|
|
|
throw new BadRequestException('Only STORED inventory can be reserved.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bookingId = dto.bookingId ?? item.bookingId;
|
|
|
|
|
await this.assertPaidBooking(this.dataSource.manager, bookingId);
|
|
|
|
|
|
|
|
|
|
await this.inventoryRepository.update(id, {
|
|
|
|
|
bookingId,
|
|
|
|
|
status: 'RESERVED',
|
|
|
|
|
});
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Loading records (Batch 3) ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async findLoadings(
|
|
|
|
|
@@ -663,44 +735,13 @@ export class WarehouseInventoryService {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
|
|
|
|
const item = await this.findById(id);
|
|
|
|
|
|
|
|
|
|
if (item.status !== 'UNDER_INSPECTION') {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.inventoryRepository.update(id, {
|
|
|
|
|
status: 'READY_FOR_LOADING',
|
|
|
|
|
readyForLoadingAt: new Date(),
|
|
|
|
|
dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
|
|
|
|
return this.transition(id, 'DISPATCHED', {
|
|
|
|
|
timestampField: 'dispatchedAt',
|
|
|
|
|
activityType: 'INVENTORY_DISPATCHED',
|
|
|
|
|
description: 'Inventory dispatched',
|
|
|
|
|
performedBy,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async load(id: string): Promise<WarehouseInventory> {
|
|
|
|
|
const item = await this.findById(id);
|
|
|
|
|
|
|
|
|
|
if (item.status !== 'READY_FOR_LOADING') {
|
|
|
|
|
throw new BadRequestException(`Only READY_FOR_LOADING inventory can be loaded (current: ${item.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.assertPaidBooking(this.dataSource.manager, item.bookingId);
|
|
|
|
|
await this.inventoryRepository.update(id, { status: 'LOADED' });
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async dispatch(id: string): Promise<WarehouseInventory> {
|
|
|
|
|
const item = await this.findById(id);
|
|
|
|
|
|
|
|
|
|
if (item.status !== 'LOADED') {
|
|
|
|
|
throw new BadRequestException(`Only LOADED inventory can be dispatched (current: ${item.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.inventoryRepository.update(id, { status: 'DISPATCHED' });
|
|
|
|
|
return this.findById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise<WarehouseDashboardSummary> {
|
|
|
|
|
@@ -858,7 +899,7 @@ export class WarehouseInventoryService {
|
|
|
|
|
|
|
|
|
|
private async validateLocation(
|
|
|
|
|
manager: EntityManager,
|
|
|
|
|
dto: ReceiveWarehouseInventoryDto,
|
|
|
|
|
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`);
|
|
|
|
|
@@ -879,6 +920,38 @@ export class WarehouseInventoryService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private assertCapacity(
|
|
|
|
|
label: string,
|
|
|
|
|
node: LocationNode,
|
|
|
|
|
@@ -909,21 +982,24 @@ export class WarehouseInventoryService {
|
|
|
|
|
|
|
|
|
|
private async applyCapacityDelta(
|
|
|
|
|
manager: EntityManager,
|
|
|
|
|
dto: ReceiveWarehouseInventoryDto,
|
|
|
|
|
location: LocationRef,
|
|
|
|
|
weightAdd: number,
|
|
|
|
|
volumeAdd: number,
|
|
|
|
|
containerAdd: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
|
|
|
|
|
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
|
|
|
|
|
[Warehouse, warehouseId],
|
|
|
|
|
[WarehouseYard, yardId],
|
|
|
|
|
[WarehouseZone, zoneId],
|
|
|
|
|
[Warehouse, location.warehouseId],
|
|
|
|
|
[WarehouseYard, location.yardId],
|
|
|
|
|
[WarehouseZone, location.zoneId],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const [entity, id] of targets) {
|
|
|
|
|
if (weight) await apply(entity, { id }, 'currentWeight', weight);
|
|
|
|
|
if (volume) await apply(entity, { id }, 'currentVolume', volume);
|
|
|
|
|
if (containers) await apply(entity, { id }, 'currentContainers', containers);
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|