import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { private readonly logger = new Logger(WarehouseInspectionService.name); constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); if (!inventory) { throw new NotFoundException(`Inventory item ${inventoryId} not found`); } const expected = dto.expectedWeight ?? null; const actual = dto.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; const inspectedAt = new Date(); const payload = { inventoryId, bookingId: inventory.bookingId ?? null, reportType: dto.reportType, inspectionStatus: dto.inspectionStatus, hasDamage: dto.hasDamage ?? false, damageDescription: dto.damageDescription ?? null, hasWeightLoss: dto.hasWeightLoss ?? false, expectedWeight: expected, actualWeight: actual, weightLoss, weightLossUnit: weightLoss !== null ? 't' : null, hasMissingItems: dto.hasMissingItems ?? false, missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, inspectedById: dto.inspectedById ?? null, inspectedAt, }; const [existingReport] = await this.inspectionRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' }, take: 1, }); let report: WarehouseInspectionReport; if (existingReport) { await this.inspectionRepository.update(existingReport.id, payload); report = await this.findById(existingReport.id); } else { report = await this.inspectionRepository.create(payload); } // Mirror the latest outcome onto the inventory item so loading rules can read it. await inventoryRepo.update(inventoryId, { inspectionStatus: dto.inspectionStatus, inspectedAt, }); if (dto.inspectionStatus === 'PASSED') { await this.markImportPickupReadyAndAcceptLastMile(inventoryId); } return report; } private async markImportPickupReadyAndAcceptLastMile(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], ); if ((row?.tradeDirection ?? '').toUpperCase() !== 'IMPORT') return; await this.dataSource.getRepository(WarehouseInventory).update(inventoryId, { status: 'READY_FOR_PICKUP', readyForPickupAt: new Date(), }); // service_types.includes_last_mile is NOT read here — every service type // ships with it true, which made this always true regardless of the // customer's actual self-haul/EDR-haul choice and permanently dead-coded // the self-haul nudge below. The delivery address is the real signal. const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.()); if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); } else if (!hasLastMile && !row.customerTruckAssignedAt) { // Self-haul import: goods are pickup-ready but no collection truck is // assigned yet — nudge the customer to assign one from the portal. void this.notifyTruckAssignmentNeeded(row); } } /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ private async notifyTruckAssignmentNeeded(row: { bookingId?: string | null; bookingReference?: string | null; companyId?: string | null; }): Promise { if (!row.companyId || !row.bookingId) return; const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; try { await this.inbox.notify({ recipients: { companyId: row.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title: 'Assign a truck for pickup', body, link: `/bookings/${row.bookingId}`, data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, }); await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); } catch (err) { this.logger.warn( `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, ); } } async findByInventory(inventoryId: string): Promise { return this.inspectionRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' }, }); } async findById(id: string): Promise { const report = await this.inspectionRepository.findById(id); if (!report) { throw new NotFoundException(`Inspection report ${id} not found`); } return report; } async update(id: string, dto: UpdateInspectionReportDto): Promise { const report = await this.findById(id); const expected = dto.expectedWeight ?? report.expectedWeight ?? null; const actual = dto.actualWeight ?? report.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null; await this.inspectionRepository.update(id, { ...(dto.reportType ? { reportType: dto.reportType } : {}), ...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}), ...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}), ...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}), ...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}), expectedWeight: expected, actualWeight: actual, weightLoss, ...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}), ...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}), ...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}), }); if (dto.inspectionStatus) { await this.dataSource .getRepository(WarehouseInventory) .update(report.inventoryId, { inspectionStatus: dto.inspectionStatus }); if (dto.inspectionStatus === 'PASSED') { await this.markImportPickupReadyAndAcceptLastMile(report.inventoryId); } } return this.findById(id); } /** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */ async addAttachments(reportId: string, files: Express.Multer.File[]) { await this.findById(reportId); if (!files?.length) return []; return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files); } listAttachments(reportId: string) { return this.filesService.findByResource(reportId, INSPECTION_RESOURCE); } }