mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
automation
This commit is contained in:
@@ -97,6 +97,10 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
||||
status!: WarehouseInventoryStatus;
|
||||
|
||||
// Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected.
|
||||
@Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true })
|
||||
inspectionStatus?: string | null;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@@ -37,6 +38,33 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('arrival-queue')
|
||||
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||
arrivalQueue() {
|
||||
return this.inventoryService.arrivalQueue();
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
autoUnloadArrived() {
|
||||
return this.inventoryService.autoUnloadArrived();
|
||||
}
|
||||
|
||||
@Post('auto-load-ready')
|
||||
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||
autoLoadReady() {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: UnloadBookingDto,
|
||||
) {
|
||||
return this.inventoryService.unloadBooking(bookingId, dto);
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import {
|
||||
@@ -55,6 +56,61 @@ interface LocationNode {
|
||||
currentContainers: number;
|
||||
}
|
||||
|
||||
// ── Batch 4.5 result/queue shapes ────────────────────────────────────────────
|
||||
interface ArrivalQueueRow {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customer: string | null;
|
||||
cargo: string | null;
|
||||
container: string | null;
|
||||
arrivalDate: Date | null;
|
||||
bookingStatus: string;
|
||||
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;
|
||||
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;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
}
|
||||
|
||||
export interface AutoUnloadResult {
|
||||
processedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface AutoLoadResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
constructor(
|
||||
@@ -107,6 +163,200 @@ export class WarehouseInventoryService {
|
||||
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 }[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.cargo_total_weight_vgm AS weight
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
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 location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
return {
|
||||
...result,
|
||||
failedCount: arrived.length,
|
||||
results: arrived.map((b) => ({ bookingId: b.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' })),
|
||||
};
|
||||
}
|
||||
|
||||
for (const booking of arrived) {
|
||||
try {
|
||||
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: '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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||
@@ -12,6 +14,9 @@ import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
import { WarehouseInspectionController } from './warehouse-inspection.controller';
|
||||
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
import { WarehouseInventoryController } from './warehouse-inventory.controller';
|
||||
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
@@ -39,7 +44,9 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInventoryMovement,
|
||||
WarehouseActivityLog,
|
||||
WarehouseLoading,
|
||||
WarehouseInspectionReport,
|
||||
]),
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
@@ -47,6 +54,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseZonesController,
|
||||
WarehouseInventoryController,
|
||||
WarehouseLoadingsController,
|
||||
WarehouseInspectionController,
|
||||
],
|
||||
providers: [
|
||||
WarehousesRepository,
|
||||
@@ -56,12 +64,14 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInventoryMovementRepository,
|
||||
WarehouseActivityLogRepository,
|
||||
WarehouseLoadingRepository,
|
||||
WarehouseInspectionRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
WarehouseInspectionService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user