import { Injectable, NotFoundException, BadRequestException, Logger, } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { NotificationsService } from "../notifications/notifications.service"; function genCode(): string { const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); return `CON-${date}-${rand}`; } @Injectable() export class ConsolidationsService { private readonly logger = new Logger(ConsolidationsService.name); constructor( private prisma: PrismaService, private notifications: NotificationsService, ) {} /** Lista consolidaciones del tenant. Cliente solo ve las suyas. */ async list(tenantId: string, userId?: string): Promise { return this.prisma.client.consolidation.findMany({ where: { tenantId, ...(userId ? { userId } : {}) }, include: { packages: { include: { package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, }, }, }, orderBy: { createdAt: "desc" }, }); } /** Obtiene una consolidación por ID */ async findOne(id: string, tenantId: string): Promise { const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId }, include: { packages: { include: { package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, }, }, }, }); if (!c) throw new NotFoundException("Consolidación no encontrada"); return c; } /** Crea una consolidación vacía para un cliente */ async create(tenantId: string, userId: string, notes?: string): Promise { return this.prisma.client.consolidation.create({ data: { tenantId, userId, code: genCode(), notes, status: "ABIERTA" }, }); } /** Agrega un paquete a la consolidación (§21 — solo paquetes VERIFICADOS) */ async addPackage(id: string, packageId: string, tenantId: string): Promise { const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); if (!c) throw new NotFoundException("Consolidación no encontrada"); if (c.status !== "ABIERTA") throw new BadRequestException("Solo se pueden agregar paquetes a consolidaciones ABIERTAS"); const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } }); if (!pkg) throw new NotFoundException("Paquete no encontrado"); if (!["VERIFICADO", "RECIBIDO_BODEGA"].includes(pkg.status)) { throw new BadRequestException(`El paquete debe estar en estado VERIFICADO o RECIBIDO_BODEGA. Estado actual: ${pkg.status}`); } // Verificar que no esté ya en otra consolidación const existing = await this.prisma.client.consolidationPackage.findUnique({ where: { packageId } }); if (existing) throw new BadRequestException("El paquete ya está en una consolidación"); await this.prisma.client.consolidationPackage.create({ data: { consolidationId: id, packageId }, }); return this.recalcTotals(id, tenantId); } /** Quita un paquete de la consolidación */ async removePackage(id: string, packageId: string, tenantId: string): Promise { const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); if (!c) throw new NotFoundException("Consolidación no encontrada"); if (c.status !== "ABIERTA") throw new BadRequestException("No se pueden quitar paquetes de una consolidación cerrada"); await this.prisma.client.consolidationPackage.deleteMany({ where: { consolidationId: id, packageId }, }); return this.recalcTotals(id, tenantId); } /** Cierra la consolidación y la marca lista para despacho */ async close(id: string, tenantId: string, courierTracking?: string): Promise { const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId }, include: { packages: true }, }); if (!c) throw new NotFoundException("Consolidación no encontrada"); if (c.status !== "ABIERTA") throw new BadRequestException("La consolidación ya está cerrada"); if (c.packages.length === 0) throw new BadRequestException("No se puede cerrar una consolidación vacía"); return this.prisma.client.consolidation.update({ where: { id }, data: { status: "CERRADA", courierTracking: courierTracking ?? null }, }); } /** Marca como despachada (courier recogió el paquete) */ async dispatch(id: string, tenantId: string, courierTracking: string): Promise { const c = await this.prisma.client.consolidation.findFirst({ where: { id, tenantId } }); if (!c) throw new NotFoundException("Consolidación no encontrada"); if (c.status !== "CERRADA") throw new BadRequestException("La consolidación debe estar CERRADA para despachar"); // Actualizar todos los paquetes a EN_TRANSITO_ECUADOR const pkgLinks = await this.prisma.client.consolidationPackage.findMany({ where: { consolidationId: id }, select: { packageId: true }, }); await this.prisma.client.package.updateMany({ where: { id: { in: pkgLinks.map(p => p.packageId) } }, data: { status: "EN_TRANSITO_ECUADOR" }, }); // Notificar a cada cliente cuyo paquete fue despachado for (const { packageId } of pkgLinks) { try { const pkg = await this.prisma.client.package.findUnique({ where: { id: packageId }, include: { user: true }, }); if (pkg) { await this.notifications.notifyStatusChange(pkg, pkg.user); } } catch (err: any) { this.logger.warn(`[CONSOLIDATION] Notif failed for pkg ${packageId}: ${err.message}`); } } this.logger.log(`[CONSOLIDATION] Dispatched ${id} — ${pkgLinks.length} packages → EN_TRANSITO_ECUADOR`); return this.prisma.client.consolidation.update({ where: { id }, data: { status: "DESPACHADA", courierTracking }, }); } /** Recalcula totales de peso y valor */ private async recalcTotals(id: string, _tenantId: string): Promise { const cp = await this.prisma.client.consolidationPackage.findMany({ where: { consolidationId: id }, include: { package: { select: { declaredValue: true, actualWeight: true, declaredWeight: true } } }, }); const totalWeightLb = cp.reduce((s, cp) => s + Number(cp.package.actualWeight ?? cp.package.declaredWeight ?? 0), 0); const totalValue = cp.reduce((s, cp) => s + Number(cp.package.declaredValue ?? 0), 0); return this.prisma.client.consolidation.update({ where: { id }, data: { totalWeightLb: Math.round(totalWeightLb * 100) / 100, totalValue: Math.round(totalValue * 100) / 100, }, include: { packages: { include: { package: { select: { id: true, trackingId: true, description: true, declaredValue: true, actualWeight: true, status: true } }, }, }, }, }); } }