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:
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user