import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { NotificationsService } from "../notifications/notifications.service"; import { SenaeService } from "../senae/senae.service"; import { generateTrackingId } from "../common/utils/tracking-id.util"; import { CreatePackageDto, RegisterPackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto"; @Injectable() export class PackagesService { constructor( private prisma: PrismaService, private notifications: NotificationsService, private senae: SenaeService, ) {} async findAll(user: any, filters?: { status?: string; search?: string }): Promise { const where: any = { tenantId: user.tenantId }; if (user.role === "CLIENTE") { where.userId = user.id; } if (filters?.status) where.status = filters.status; if (filters?.search) { where.OR = [ { trackingId: { contains: filters.search, mode: "insensitive" } }, { vendorTracking: { contains: filters.search, mode: "insensitive" } }, { description: { contains: filters.search, mode: "insensitive" } }, ]; } return this.prisma.client.package.findMany({ where, include: { user: { select: { firstName: true, lastName: true, email: true } }, statusHistory: { orderBy: { createdAt: "desc" }, take: 1 }, }, orderBy: { createdAt: "desc" }, }); } async findOne(id: string, user: any): Promise { const pkg = await this.prisma.client.package.findUnique({ where: { id }, include: { user: { select: { firstName: true, lastName: true, email: true } }, statusHistory: { orderBy: { createdAt: "desc" } }, preAlert: true, }, }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); if (user.role === "CLIENTE" && pkg.userId !== user.id) throw new ForbiddenException(); return pkg; } async create(dto: CreatePackageDto, operatorId: string, tenantId: string): Promise { const trackingId = generateTrackingId(); const pkg = await this.prisma.client.package.create({ data: { trackingId, tenantId, userId: dto.userId, description: dto.description, store: dto.store, vendorTracking: dto.vendorTracking, productUrl: dto.productUrl, declaredValue: dto.declaredValue ?? 0, declaredWeight: dto.declaredWeightLb ?? null, lengthCm: dto.lengthCm ?? null, widthCm: dto.widthCm ?? null, heightCm: dto.heightCm ?? null, status: "REGISTRADO", }, }); await this.prisma.client.packageStatusHistory.create({ data: { packageId: pkg.id, status: "REGISTRADO", createdBy: operatorId, note: "Paquete registrado en el sistema", }, }); // Intentar vincular con pre-alerta pendiente del mismo usuario (§09) await this.tryLinkPreAlert(pkg.id, dto.userId, tenantId, dto.vendorTracking); return pkg; } async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise { const pkg = await this.prisma.client.package.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); const updated = await this.prisma.client.package.update({ where: { id }, data: { status: dto.status as any }, }); await this.prisma.client.packageStatusHistory.create({ data: { packageId: id, status: dto.status as any, createdBy: operatorId, note: dto.note, }, }); this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {}); return updated; } /** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking * y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */ private async tryLinkPreAlert( packageId: string, userId: string, tenantId: string, vendorTracking?: string, ): Promise { try { const where: any = { tenantId, userId, status: "PENDIENTE", packageId: null }; if (vendorTracking) where.vendorTracking = vendorTracking; const alert = await this.prisma.client.preAlert.findFirst({ where, orderBy: { createdAt: "desc" }, }); if (!alert) return; await this.prisma.client.preAlert.update({ where: { id: alert.id }, data: { packageId, status: "VINCULADA" }, }); } catch { // Non-blocking — linking failure must not block package creation } } /** Cliente registra su propia compra — doc §09 pasos 5-6 */ async selfRegister(dto: RegisterPackageDto, userId: string, tenantId: string): Promise { const trackingId = generateTrackingId(); const pkg = await this.prisma.client.package.create({ data: { trackingId, tenantId, userId, description: dto.description, store: dto.store, vendorTracking: dto.vendorTracking, productUrl: dto.productUrl, declaredValue: dto.declaredValue ?? 0, declaredWeight: dto.declaredWeightLb ?? null, senaeCategory: dto.senaeCategory as any ?? null, status: "REGISTRADO", }, }); await this.prisma.client.packageStatusHistory.create({ data: { packageId: pkg.id, status: "REGISTRADO", createdBy: userId, note: "Compra registrada por el cliente", }, }); await this.tryLinkPreAlert(pkg.id, userId, tenantId, dto.vendorTracking); // Notify the user of registration this.notifications.notifyStatusChange(pkg, { id: userId }).catch(() => {}); return pkg; } /** * Bodega verification: record actual weight, dims, detect discrepancy >10% (doc §10 step 4). * Sets status to EN_VERIFICACION then VERIFICADO. */ async verifyPackage(id: string, dto: VerifyPackageDto, operatorId: string): Promise { const pkg = await this.prisma.client.package.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); // Detect discrepancy: >10% difference between declared and actual weight let hasDiscrepancy = false; if (pkg.declaredWeight && dto.actualWeightLb) { const declared = Number(pkg.declaredWeight); const actual = dto.actualWeightLb; const diff = Math.abs(actual - declared) / declared; hasDiscrepancy = diff > 0.10; } const updated = await this.prisma.client.package.update({ where: { id }, data: { actualWeight: dto.actualWeightLb, lengthCm: dto.lengthCm ?? pkg.lengthCm, widthCm: dto.widthCm ?? pkg.widthCm, heightCm: dto.heightCm ?? pkg.heightCm, hasDiscrepancy, status: "VERIFICADO", }, }); await this.prisma.client.packageStatusHistory.create({ data: { packageId: id, status: "VERIFICADO", createdBy: operatorId, note: dto.note ?? (hasDiscrepancy ? `DISCREPANCIA: peso declarado ${pkg.declaredWeight}lb vs real ${dto.actualWeightLb}lb` : `Verificado: peso real ${dto.actualWeightLb}lb`), }, }); // Notify user on verification this.notifications.notifyStatusChange({ ...updated, hasDiscrepancy }, { id: updated.userId }).catch(() => {}); return { ...updated, hasDiscrepancy }; } /** * Add photo URLs to a package (doc §10 step 4, §07 Portal Bodega). */ async addPhotos(id: string, photoUrls: string[], operatorId: string): Promise { const pkg = await this.prisma.client.package.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); const updated = await this.prisma.client.package.update({ where: { id }, data: { photos: { push: photoUrls } }, }); return updated; } /** * SENAE declaration (doc §11): call SenaeService (real or stub), update status to DECLARACION_ADUANERA. */ async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise { const pkg = await this.prisma.client.package.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException("Paquete no encontrado."); if (pkg.status !== "VERIFICADO") { throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración."); } // Call real SENAE service (falls back to stub if credentials not set — C-1) const { authNumber, declarationId, message } = await this.senae.submitDSI( pkg, pkg.tenantId, dto.category, dto.agentNotes, ); const updated = await this.prisma.client.package.update({ where: { id }, data: { status: "DECLARACION_ADUANERA", senaeCategory: dto.category as any, senaeAuthNumber: authNumber, senaeDeclarationId: declarationId, }, }); await this.prisma.client.packageStatusHistory.create({ data: { packageId: id, status: "DECLARACION_ADUANERA", createdBy: agentId, note: dto.agentNotes ?? `DSI generada. Auth: ${authNumber}. Categoría: ${dto.category}`, }, }); this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {}); return { ...updated, declarationId, authNumber, message }; } /** * Queue for SENAE: packages with status VERIFICADO pending declaration. */ async findPendingDeclaration(tenantId: string): Promise { return this.prisma.client.package.findMany({ where: { tenantId, status: "VERIFICADO" }, include: { user: { select: { firstName: true, lastName: true, email: true } }, statusHistory: { orderBy: { createdAt: "desc" }, take: 1 }, }, orderBy: { updatedAt: "asc" }, }); } }