## 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
516 lines
17 KiB
Plaintext
516 lines
17 KiB
Plaintext
// 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])
|
||
}
|