feat: consolidations, aduanero portal, payments admin, bug fixes, tests

## Bugs corregidos
- bodega/page.tsx: estados correctos §08 (RECIBIDO_BODEGA, EN_VERIFICACION, EN_TRANSITO_ECUADOR)
- admin/page.tsx: carga paquetes reales, alertas de incidencias/B2B/cobros pendientes, barras de estado

## Nuevos portales y páginas
- /aduanero/ — portal propio para AGENTE_ADUANERO (layout + dashboard §11 + declaraciones DSI)
- /aduanero/declaraciones — cola de declaraciones con formulario DSI y detección DAI automática
- /admin/pagos — gestión de cobros: KPIs, filtros por estado, tabla con breakdown
- /portal/consolidacion — cliente crea/gestiona consolidaciones §21
- /bodega/consolidacion — operador cierra y despacha consolidaciones (→ EN_TRANSITO_ECUADOR)

## API nueva (ConsolidationsModule)
- GET/POST /consolidations
- GET /consolidations/:id
- POST /consolidations/:id/packages
- DELETE /consolidations/:id/packages/:packageId
- POST /consolidations/:id/close
- POST /consolidations/:id/dispatch (→ actualiza paquetes a EN_TRANSITO_ECUADOR)

## Prisma schema v0.5
- ConsolidationStatus enum (ABIERTA, CERRADA, DESPACHADA, ENTREGADA, CANCELADA)
- Consolidation model con totales calculados (totalWeightLb, totalValue)
- ConsolidationPackage (tabla intermedia, un paquete = una consolidación)
- db push aplicado a remote DB (46.202.93.92)

## Navegación
- Login: AGENTE_ADUANERO → /aduanero (ya no /bodega)
- Bodega nav: añadido Consolidaciones
- Portal nav: añadido Consolidar
- Admin nav: añadido Cobros

## Tests (104 total, 8 suites)
- payments.service.spec.ts: 16 tests (createIntent, confirm, list, findByPackageForUser)
- notifications.service.spec.ts: 12 tests (getTemplates, updateTemplate, seedDefaultTemplates, notifyStatusChange, findByUser)

## Legal §21
- Registro: aviso LOPDP Ecuador + normativa NJ en footer del formulario
- Landing footer: aviso detallado de protección de datos LOPDP/NJ

## api.ts: consolidations.* client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:29:26 -05:00
parent 5565eef554
commit 7682b11dff
23 changed files with 2158 additions and 95 deletions
@@ -0,0 +1,162 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.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) {}
/** Lista consolidaciones del tenant. Cliente solo ve las suyas. */
async list(tenantId: string, userId?: string): Promise<any[]> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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 pkgIds = await this.prisma.client.consolidationPackage.findMany({
where: { consolidationId: id },
select: { packageId: true },
});
await this.prisma.client.package.updateMany({
where: { id: { in: pkgIds.map(p => p.packageId) } },
data: { status: "EN_TRANSITO_ECUADOR" },
});
this.logger.log(`[CONSOLIDATION] Dispatched ${id}${pkgIds.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<any> {
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 } },
},
},
},
});
}
}