297 lines
9.8 KiB
TypeScript
297 lines
9.8 KiB
TypeScript
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<any[]> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<void> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any> {
|
|
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<any[]> {
|
|
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" },
|
|
});
|
|
}
|
|
}
|