Files
Lizandro Guarnizo 7682b11dff 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
2026-06-01 18:29:26 -05:00

516 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Moraworld Imports — Schema v0.5 (Fase 3 — Consolidación + Pagos + Plantillas)
// Multi-tenant por tenant_id en todas las tablas de negocio
// Sincronizado con documentacion.html v1.1
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum ConsolidationStatus {
ABIERTA // Recibiendo paquetes
CERRADA // Lista para despacho
DESPACHADA // Enviada al courier internacional
ENTREGADA // Entregada en Ecuador
CANCELADA
}
enum PaymentStatus {
PENDIENTE
PROCESANDO
COMPLETADO
FALLIDO
REEMBOLSADO
}
// ─── Enums ───────────────────────────────────────────────────
enum UserRole {
SUPER_ADMIN
ADMIN_EMPRESA
OPERADOR_BODEGA
AGENTE_ADUANERO
CLIENTE
SOPORTE
}
/// 11 estados del ciclo de vida de un paquete (doc §08)
enum PackageStatus {
REGISTRADO
EN_TRANSITO_BODEGA
RECIBIDO_BODEGA
EN_VERIFICACION
VERIFICADO
DECLARACION_ADUANERA
EN_TRANSITO_ECUADOR
EN_ADUANA_ECUADOR
LISTO_ENTREGA
ENTREGADO
INCIDENCIA
}
enum PreAlertStatus {
PENDIENTE
VINCULADA
CANCELADA
}
/// Categorías SENAE para el cálculo de aranceles (doc §15)
enum SenaeCategory {
REGIMEN_4X4 // 0% — hasta $400, 4 kg, 4 envíos/año
CATEGORIA_B // 10% — bienes generales
CATEGORIA_C // 20% — textiles, calzado, hogar
CATEGORIA_D // 0-15% — electrónicos y telecomunicaciones
}
enum B2BRequestStatus {
PENDIENTE
EN_COTIZACION
COTIZADO
ACEPTADO
EN_PROCESO
COMPLETADO
CANCELADO
}
enum NotificationChannel {
EMAIL
WHATSAPP
SMS
PUSH
}
enum NotificationStatus {
PENDIENTE
ENVIADO
FALLIDO
}
// ─── Tenant (multi-tenant) ───────────────────────────────────
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
suites Suite[]
packages Package[]
preAlerts PreAlert[]
tariffs Tariff[]
b2bRequests B2BRequest[]
warehouses Warehouse[]
integrations Integration[]
notificationTemplates NotificationTemplate[]
payments Payment[]
consolidations Consolidation[]
}
// ─── Usuarios ────────────────────────────────────────────────
model User {
id String @id @default(cuid())
tenantId String
email String
passwordHash String
firstName String
lastName String
phone String?
role UserRole @default(CLIENTE)
mfaEnabled Boolean @default(false)
mfaSecret String?
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
suite Suite?
packages Package[]
preAlerts PreAlert[]
refreshTokens RefreshToken[]
@@unique([tenantId, email])
@@index([tenantId])
}
/// Tokens de refresco JWT — expiración y revocación (doc §05 Auth)
model RefreshToken {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// ─── Casillero / Suite ───────────────────────────────────────
/// Dirección virtual asignada al registrarse: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
model Suite {
id String @id @default(cuid())
tenantId String
userId String @unique
code String // ej: EC-00345
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tenantId, code])
@@index([tenantId])
}
// ─── Paquetes ────────────────────────────────────────────────
model Package {
id String @id @default(cuid())
tenantId String
userId String
/// Formato: EC-YYYYMMDD-XXXXXX (doc §08)
trackingId String @unique
status PackageStatus @default(REGISTRADO)
description String
store String?
declaredValue Decimal @db.Decimal(10, 2)
/// Peso declarado por el cliente en libras
declaredWeight Decimal? @db.Decimal(8, 2)
/// Peso real confirmado por el operador de bodega en libras
actualWeight Decimal? @db.Decimal(8, 2)
/// Largo en cm — para peso volumétrico (doc §15)
lengthCm Decimal? @db.Decimal(8, 2)
/// Ancho en cm
widthCm Decimal? @db.Decimal(8, 2)
/// Alto en cm
heightCm Decimal? @db.Decimal(8, 2)
/// Hay discrepancia >10% entre peso declarado y real (doc §10)
hasDiscrepancy Boolean @default(false)
vendorTracking String?
productUrl String?
/// URL de fotos del paquete en S3
photos String[]
/// Categoría SENAE asignada al calcular (doc §15)
senaeCategory SenaeCategory?
/// N.º de autorización SENAE al completar la DSI (doc §08 estado 06)
senaeAuthNumber String?
/// ID de la declaración enviada al WebService SENAE
senaeDeclarationId String?
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
statusHistory PackageStatusHistory[]
preAlert PreAlert?
notifications Notification[]
payment Payment?
consolidation ConsolidationPackage?
@@index([tenantId, status])
@@index([userId])
}
model PackageStatusHistory {
id String @id @default(cuid())
packageId String
status PackageStatus
note String?
createdBy String?
createdAt DateTime @default(now())
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@index([packageId])
}
// ─── Pre-alertas ─────────────────────────────────────────────
model PreAlert {
id String @id @default(cuid())
tenantId String
/// Cliente que creó la pre-alerta
userId String
packageId String? @unique
store String
vendorTracking String?
description String
declaredValue Decimal @db.Decimal(10, 2)
/// S3 key de la factura cargada (PDF o imagen)
invoiceKey String?
/// Fecha estimada de llegada a bodega NJ
estimatedArrival DateTime?
status PreAlertStatus @default(PENDIENTE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
package Package? @relation(fields: [packageId], references: [id])
@@index([tenantId])
@@index([userId])
}
// ─── Tarifas (configurables por Admin) ───────────────────────
/// Configuración de tarifas por tenant. Flete $/lb, seguro %, etc. (doc §12 + §15)
model Tariff {
id String @id @default(cuid())
tenantId String @unique
/// Precio por libra en USD — default: $3.50 (doc §15)
pricePerLb Decimal @db.Decimal(8, 4) @default(3.50)
/// Porcentaje de seguro sobre valor declarado — default: 2% (doc §15)
insurancePct Decimal @db.Decimal(5, 4) @default(0.02)
/// FODINFA — fijo SENAE: 0.5% (doc §15)
fodinfaPct Decimal @db.Decimal(5, 4) @default(0.005)
/// IVA Ecuador — 15% (doc §15)
ivaPct Decimal @db.Decimal(5, 4) @default(0.15)
/// Límite 4×4: valor máx. USD (doc §08 estado 06 / §15)
max4x4Value Decimal @db.Decimal(10, 2) @default(400)
/// Límite 4×4: peso máx. kg
max4x4WeightKg Decimal @db.Decimal(6, 2) @default(4)
/// Máx. envíos/año bajo régimen 4×4
max4x4PerYear Int @default(4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}
// ─── Carga Pesada B2B ─────────────────────────────────────────
/// Solicitudes de importación mayorista: pallets, contenedores (doc §13)
model B2BRequest {
id String @id @default(cuid())
tenantId String
/// Tracking ID propio para B2B
trackingId String @unique
contactName String
contactEmail String
contactPhone String?
companyName String?
/// Tipo de mercancía
merchandiseType String
/// Descripción detallada
description String
/// Peso estimado en kg
estimatedWeightKg Decimal? @db.Decimal(10, 2)
/// Número de pallets
pallets Int?
/// Valor comercial total en USD
commercialValue Decimal? @db.Decimal(12, 2)
/// Ciudad de origen en EE.UU.
originCity String?
/// Requiere certificación INEN (doc §13)
requiresInen Boolean @default(false)
inenCertNumber String?
status B2BRequestStatus @default(PENDIENTE)
/// Cotización enviada por el equipo Moraworld
quotationAmount Decimal? @db.Decimal(12, 2)
quotationNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, status])
}
// ─── Notificaciones ──────────────────────────────────────────
/// Historial de notificaciones enviadas por canal (doc §16)
model Notification {
id String @id @default(cuid())
packageId String?
userId String?
channel NotificationChannel
status NotificationStatus @default(PENDIENTE)
subject String?
body String
sentAt DateTime?
error String?
createdAt DateTime @default(now())
package Package? @relation(fields: [packageId], references: [id])
@@index([packageId])
@@index([userId])
}
// ─── Auditoría (append-only) ───────────────────────────────
/// Logs inmutables — ISO 27001 A.12 (doc §17)
model AuditLog {
id String @id @default(cuid())
tenantId String?
userId String?
action String
resource String?
resourceId String?
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
@@index([userId])
}
// ─── Bodegas (multi-bodega, doc §07 + §12) ───────────────────
/// Bodega física. Cada tenant puede tener varias; una es la default.
model Warehouse {
id String @id @default(cuid())
tenantId String
name String // ej: "Bodega NJ — City of Orange"
street String // ej: "150 N Day St"
city String // ej: "City of Orange"
state String // ej: "NJ"
zip String // ej: "07050"
country String @default("US")
phone String?
email String?
contactName String?
/// Horario de operación (texto libre)
schedule String?
/// Capacidad aproximada (m²)
capacityM2 Decimal? @db.Decimal(10, 2)
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
}
// ─── Consolidaciones (§21) ────────────────────────────────────
/// Agrupa múltiples paquetes del mismo cliente en un solo envío.
/// Optimiza costos de flete y reduce declaraciones aduaneras.
model Consolidation {
id String @id @default(cuid())
tenantId String
userId String // cliente dueño de los paquetes
/// Código de consolidación: CON-YYYYMMDD-XXXXXX
code String @unique
status ConsolidationStatus @default(ABIERTA)
/// Peso total real en libras (suma de paquetes)
totalWeightLb Decimal? @db.Decimal(8, 2)
/// Valor declarado total en USD
totalValue Decimal? @db.Decimal(10, 2)
/// Notas del operador
notes String?
/// Número de seguimiento del courier internacional (FedEx / DHL / UPS)
courierTracking String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
packages ConsolidationPackage[]
@@index([tenantId, status])
@@index([userId])
}
/// Tabla intermedia — paquetes incluidos en una consolidación
model ConsolidationPackage {
id String @id @default(cuid())
consolidationId String
packageId String @unique // un paquete solo puede estar en una consolidación
addedAt DateTime @default(now())
consolidation Consolidation @relation(fields: [consolidationId], references: [id], onDelete: Cascade)
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@index([consolidationId])
}
// ─── Pagos (§09 paso 8 / §14 paso 5) ─────────────────────────
/// Registro de pagos vinculados a un paquete.
/// El `providerRef` es el PaymentIntent ID de Stripe (o equivalente).
model Payment {
id String @id @default(cuid())
tenantId String
packageId String @unique
userId String
amount Decimal @db.Decimal(10, 2)
currency String @default("USD")
/// "stripe" | "payphone" | "paypal"
provider String @default("stripe")
/// PaymentIntent ID de Stripe u equivalente
providerRef String?
status PaymentStatus @default(PENDIENTE)
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@index([tenantId, status])
@@index([userId])
}
// ─── Plantillas de notificación (§12) ────────────────────────
/// Plantillas de mensajes para cada evento del ciclo de vida del paquete.
/// Soporta variables: {{trackingId}}, {{firstName}}, {{status}}, {{suiteCode}}
model NotificationTemplate {
id String @id @default(cuid())
tenantId String
/// Evento que dispara la notificación — coincide con PackageStatus
event String // ej: "REGISTRADO", "ENTREGADO"
channel NotificationChannel
subject String? // Solo relevante para EMAIL
body String
isActive Boolean @default(true)
updatedAt DateTime @updatedAt
updatedBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, event, channel])
@@index([tenantId, event])
}
// ─── Integraciones (API keys por tenant) ─────────────────────
/// Configuración de integraciones externas (API keys, endpoints, webhooks).
/// El valor se almacena en texto plano en dev; en prod debe cifrarse (AES-256).
model Integration {
id String @id @default(cuid())
tenantId String
/// Clave identificadora: ej. "whatsapp_token", "senae_endpoint", "stripe_secret"
key String
/// Valor (API key, URL, token — cifrar en prod)
value String?
/// Descripción legible del campo
label String?
/// Grupo de integración: "payment", "notifications", "customs", "courier", "warehouse"
group String?
isActive Boolean @default(false)
updatedAt DateTime @updatedAt
updatedBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, key])
@@index([tenantId, group])
}