diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 01f1b71ac..e8d068a77 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -60,7 +60,6 @@ import { RoutesModule } from './modules/routes/routes.module'; import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { FacilitiesModule } from './modules/facilities/facilities.module'; -import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; @@ -122,8 +121,6 @@ import { DriversModule } from './modules/drivers/drivers.module'; WarehousesModule, OverviewModule, FacilitiesModule, - WarehousesModule, - OverviewModule, VehiclesModule, DriversModule, ], diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e21ea87b9..362d740c8 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from "@nestjs/common"; +import { DynamicModule, Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; @@ -23,12 +23,10 @@ import { PaymentRefundEntity } from "./entities/payment-refund.entity"; const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; -@Module({ - imports: [ - HttpModule.register({ timeout: 10_000 }), - ConfigModule, - forwardRef(() => TrainSchedulingModule), - TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), +function rabbitMQImport(): DynamicModule[] { + if (!process.env.PAYMENT_RABBITMQ_URL) return []; + + return [ RabbitMQModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ @@ -49,6 +47,16 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; connectionInitOptions: { wait: false }, }), }), + ]; +} + +@Module({ + imports: [ + HttpModule.register({ timeout: 10_000 }), + ConfigModule, + forwardRef(() => TrainSchedulingModule), + TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), + ...rabbitMQImport(), ], providers: [ PaymentRepository, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index e7b67c53b..b24febf91 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -15,7 +15,6 @@ import { PaymentClientService } from "./payment-client.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { ConfigService } from "@nestjs/config"; import { Booking } from "../bookings/entities/booking.entity"; import { diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 4fcb73ef2..d898bb78b 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -1,4 +1,4 @@ -import { Entity, Column, Index } from 'typeorm'; +import { Entity, Column } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; export enum VehicleType { diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 4a9117396..9d27ed64a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -28,10 +28,6 @@ export class Warehouse extends BaseEntity { @Column({ name: 'station_id', type: 'uuid', nullable: true }) stationId?: string | null; - @ManyToOne(() => Yard, { nullable: true }) - @JoinColumn({ name: 'station_id' }) - facility?: Yard | null; - @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) locationName?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 1a8045bbb..ce8a2c188 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -142,16 +142,4 @@ export class WarehouseInventoryController { dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.dispatch(id, performedBy); } - - @Post(':id/load') - @ApiOperation({ summary: 'Mark ready inventory as loaded' }) - load(@Param('id', ParseUUIDPipe) id: string) { - return this.inventoryService.load(id); - } - - @Post(':id/dispatch') - @ApiOperation({ summary: 'Dispatch loaded inventory' }) - dispatch(@Param('id', ParseUUIDPipe) id: string) { - return this.inventoryService.dispatch(id); - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3e43ad4e8..7e4b25544 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + async move(id: string, dto: MoveInventoryDto): Promise { 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 { - 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 { - 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 { - 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 { + return this.transition(id, 'DISPATCHED', { + timestampField: 'dispatchedAt', + activityType: 'INVENTORY_DISPATCHED', + description: 'Inventory dispatched', + performedBy, }); - - return this.findById(id); - } - - async load(id: string): Promise { - 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 { - 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 { @@ -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 { + 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 { + 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 { - 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)); } } }