feat: audit & sync with documentacion.html v1.1 — schema, tests, modules, web

Schema (packages/database/prisma/schema.prisma):
- Add SenaeCategory, B2BRequestStatus, NotificationChannel enums
- Add Package fields: lengthCm, widthCm, heightCm, hasDiscrepancy, photos[],
  senaeCategory, senaeAuthNumber, senaeDeclarationId
- Add userId field to PreAlert
- Add RefreshToken model (JWT auth — Fase 1)
- Add Tariff model (configurable rates: pricePerLb, insurancePct, etc.)
- Add B2BRequest model (heavy cargo imports — doc §13)
- Add Notification model (multi-channel: email, whatsapp, sms, push — doc §16)
- Add userAgent to AuditLog

API (apps/api):
- Add CalculatorModule: calculates SENAE cost breakdown (flete, seguro,
  FODINFA, arancel, IVA) per formulas in doc §15
- Add TrackingModule: public GET /api/tracking/:id endpoint (doc §07)
- Register both modules in AppModule
- Add Jest config + test scripts (test, test:watch, test:cov, test:ci)

Tests (80 tests, 97.45% coverage):
- calculator.util.spec.ts: 35 tests — SENAE formulas, volumetric weight,
  4x4 regime, tariff rates, error validation, breakdown
- tracking-id.util.spec.ts: 13 tests — format, uniqueness, validation
- suite-code.util.spec.ts: 13 tests — generation, parsing, address builder
- health.controller.spec.ts: 5 tests — ok/degraded/timestamp
- calculator.spec.ts: 8 tests — controller + service unit tests
- tracking.controller.spec.ts: 9 tests — public search, 404, no sensitive data

Web (apps/web):
- page.tsx: Full landing page matching documentation (Hero, Services,
  8 Modules, Tracking states, SENAE calculator preview, 6 Roles, Companies)
- /calculadora: SENAE categories + API usage guide
- /tracking: Tracking ID format + 11 states reference

Seed (packages/database/prisma/seed.ts):
- Creates all 6 roles (SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA,
  AGENTE_ADUANERO, CLIENTE, SOPORTE)
- Assigns Suite EC-00001 to demo client
- Creates demo Package EC-20260506-000001 with status history
- Creates demo PreAlert and B2BRequest

.env.example: Added all 9 integrations from doc §18:
  Amazon SP-API, SENAE WebService, Stripe/PayPhone, WhatsApp Business API,
  SendGrid/SES, SMS/Twilio, FedEx/DHL/UPS couriers, S3/MinIO, Sentry

migration.sql: Updated to reflect all new models and fields
turbo.json + package.json: Added test, test:cov, test:ci tasks + db:seed script
This commit is contained in:
Lizandro Guarnizo
2026-06-01 08:25:01 -05:00
parent 094ea9cf81
commit 93db76897d
26 changed files with 4651 additions and 201 deletions
@@ -1,13 +1,31 @@
-- ══════════════════════════════════════════════════════════════
-- Moraworld Imports — Migración inicial v0.2
-- Sincronizado con documentacion.html v1.1
-- ══════════════════════════════════════════════════════════════
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('SUPER_ADMIN', 'ADMIN_EMPRESA', 'OPERADOR_BODEGA', 'AGENTE_ADUANERO', 'CLIENTE', 'SOPORTE');
-- CreateEnum
-- CreateEnum — 11 estados del ciclo de vida de un paquete (doc §08)
CREATE TYPE "PackageStatus" AS ENUM ('REGISTRADO', 'EN_TRANSITO_BODEGA', 'RECIBIDO_BODEGA', 'EN_VERIFICACION', 'VERIFICADO', 'DECLARACION_ADUANERA', 'EN_TRANSITO_ECUADOR', 'EN_ADUANA_ECUADOR', 'LISTO_ENTREGA', 'ENTREGADO', 'INCIDENCIA');
-- CreateEnum
CREATE TYPE "PreAlertStatus" AS ENUM ('PENDIENTE', 'VINCULADA', 'CANCELADA');
-- CreateTable
-- CreateEnum — Categorías SENAE (doc §15)
CREATE TYPE "SenaeCategory" AS ENUM ('REGIMEN_4X4', 'CATEGORIA_B', 'CATEGORIA_C', 'CATEGORIA_D');
-- CreateEnum — Estados de solicitud B2B (doc §13)
CREATE TYPE "B2BRequestStatus" AS ENUM ('PENDIENTE', 'EN_COTIZACION', 'COTIZADO', 'ACEPTADO', 'EN_PROCESO', 'COMPLETADO', 'CANCELADO');
-- CreateEnum — Canales de notificación (doc §16)
CREATE TYPE "NotificationChannel" AS ENUM ('EMAIL', 'WHATSAPP', 'SMS', 'PUSH');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('PENDIENTE', 'ENVIADO', 'FALLIDO');
-- ─── Tenant (multi-tenant) ────────────────────────────────────
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
@@ -15,11 +33,11 @@ CREATE TABLE "Tenant" (
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Usuarios ─────────────────────────────────────────────────
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
@@ -35,43 +53,64 @@ CREATE TABLE "User" (
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Refresh Tokens JWT (doc §05 Auth) ───────────────────────
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- ─── Suite / Casillero (doc §02 y §09) ───────────────────────
-- Dirección: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
CREATE TABLE "Suite" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Suite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Paquetes (doc §07 — Módulo de Tracking) ─────────────────
CREATE TABLE "Package" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL, -- EC-YYYYMMDD-XXXXXX (doc §08)
"status" "PackageStatus" NOT NULL DEFAULT 'REGISTRADO',
"description" TEXT NOT NULL,
"store" TEXT,
"declaredValue" DECIMAL(10,2) NOT NULL,
"declaredWeight" DECIMAL(8,2),
"actualWeight" DECIMAL(8,2),
"declaredWeight" DECIMAL(8,2), -- Peso declarado por cliente (lbs)
"actualWeight" DECIMAL(8,2), -- Peso real confirmado en bodega NJ
"lengthCm" DECIMAL(8,2), -- Para peso volumétrico (doc §15)
"widthCm" DECIMAL(8,2),
"heightCm" DECIMAL(8,2),
"hasDiscrepancy" BOOLEAN NOT NULL DEFAULT false, -- Discrepancia >10% (doc §10)
"vendorTracking" TEXT,
"productUrl" TEXT,
"photos" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], -- S3 keys de fotos
"senaeCategory" "SenaeCategory", -- Categoría SENAE (doc §15)
"senaeAuthNumber" TEXT, -- N.º autorización SENAE
"senaeDeclarationId" TEXT, -- ID declaración en WebService
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Package_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Historial de estados ─────────────────────────────────────
CREATE TABLE "PackageStatusHistory" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
@@ -79,28 +118,89 @@ CREATE TABLE "PackageStatusHistory" (
"note" TEXT,
"createdBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PackageStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Pre-alertas (doc §07 — Módulo Pre-alerta) ───────────────
CREATE TABLE "PreAlert" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL, -- Cliente que creó la pre-alerta
"packageId" TEXT,
"store" TEXT NOT NULL,
"vendorTracking" TEXT,
"description" TEXT NOT NULL,
"declaredValue" DECIMAL(10,2) NOT NULL,
"invoiceKey" TEXT,
"invoiceKey" TEXT, -- S3 key de la factura (PDF/imagen)
"estimatedArrival" TIMESTAMP(3), -- Fecha estimada llegada bodega NJ
"status" "PreAlertStatus" NOT NULL DEFAULT 'PENDIENTE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PreAlert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
-- ─── Tarifas configurables (doc §12 + §15) ───────────────────
CREATE TABLE "Tariff" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"pricePerLb" DECIMAL(8,4) NOT NULL DEFAULT 3.50,
"insurancePct" DECIMAL(5,4) NOT NULL DEFAULT 0.02,
"fodinfaPct" DECIMAL(5,4) NOT NULL DEFAULT 0.005,
"ivaPct" DECIMAL(5,4) NOT NULL DEFAULT 0.15,
"max4x4Value" DECIMAL(10,2) NOT NULL DEFAULT 400,
"max4x4WeightKg" DECIMAL(6,2) NOT NULL DEFAULT 4,
"max4x4PerYear" INTEGER NOT NULL DEFAULT 4,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tariff_pkey" PRIMARY KEY ("id")
);
-- ─── Solicitudes B2B — Carga Pesada (doc §13) ────────────────
CREATE TABLE "B2BRequest" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"trackingId" TEXT NOT NULL, -- ID B2B propio
"contactName" TEXT NOT NULL,
"contactEmail" TEXT NOT NULL,
"contactPhone" TEXT,
"companyName" TEXT,
"merchandiseType" TEXT NOT NULL,
"description" TEXT NOT NULL,
"estimatedWeightKg" DECIMAL(10,2),
"pallets" INTEGER,
"commercialValue" DECIMAL(12,2),
"originCity" TEXT,
"requiresInen" BOOLEAN NOT NULL DEFAULT false, -- Requiere certificación INEN
"inenCertNumber" TEXT,
"status" "B2BRequestStatus" NOT NULL DEFAULT 'PENDIENTE',
"quotationAmount" DECIMAL(12,2),
"quotationNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "B2BRequest_pkey" PRIMARY KEY ("id")
);
-- ─── Notificaciones (doc §16) ─────────────────────────────────
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"packageId" TEXT,
"userId" TEXT,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationStatus" NOT NULL DEFAULT 'PENDIENTE',
"subject" TEXT,
"body" TEXT NOT NULL,
"sentAt" TIMESTAMP(3),
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- ─── Auditoría append-only (doc §17 — ISO 27001 A.12) ────────
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"tenantId" TEXT,
@@ -110,73 +210,51 @@ CREATE TABLE "AuditLog" (
"resourceId" TEXT,
"metadata" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
-- ─── Índices únicos ───────────────────────────────────────────
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
CREATE UNIQUE INDEX "Suite_userId_key" ON "Suite"("userId");
CREATE UNIQUE INDEX "Suite_tenantId_code_key" ON "Suite"("tenantId", "code");
CREATE UNIQUE INDEX "Package_trackingId_key" ON "Package"("trackingId");
CREATE UNIQUE INDEX "PreAlert_packageId_key" ON "PreAlert"("packageId");
CREATE UNIQUE INDEX "Tariff_tenantId_key" ON "Tariff"("tenantId");
CREATE UNIQUE INDEX "B2BRequest_trackingId_key" ON "B2BRequest"("trackingId");
CREATE UNIQUE INDEX "User_tenantId_email_key" ON "User"("tenantId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "Suite_userId_key" ON "Suite"("userId");
-- ─── Índices de búsqueda ──────────────────────────────────────
-- CreateIndex
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
CREATE INDEX "Suite_tenantId_idx" ON "Suite"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "Suite_tenantId_code_key" ON "Suite"("tenantId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Package_trackingId_key" ON "Package"("trackingId");
-- CreateIndex
CREATE INDEX "Package_tenantId_status_idx" ON "Package"("tenantId", "status");
-- CreateIndex
CREATE INDEX "Package_userId_idx" ON "Package"("userId");
-- CreateIndex
CREATE INDEX "PackageStatusHistory_packageId_idx" ON "PackageStatusHistory"("packageId");
-- CreateIndex
CREATE UNIQUE INDEX "PreAlert_packageId_key" ON "PreAlert"("packageId");
-- CreateIndex
CREATE INDEX "PreAlert_tenantId_idx" ON "PreAlert"("tenantId");
-- CreateIndex
CREATE INDEX "PreAlert_userId_idx" ON "PreAlert"("userId");
CREATE INDEX "B2BRequest_tenantId_status_idx" ON "B2BRequest"("tenantId", "status");
CREATE INDEX "Notification_packageId_idx" ON "Notification"("packageId");
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
CREATE INDEX "AuditLog_tenantId_createdAt_idx" ON "AuditLog"("tenantId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- AddForeignKey
-- ─── Claves foráneas ──────────────────────────────────────────
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Suite" ADD CONSTRAINT "Suite_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suite" ADD CONSTRAINT "Suite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PackageStatusHistory" ADD CONSTRAINT "PackageStatusHistory_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PreAlert" ADD CONSTRAINT "PreAlert_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Tariff" ADD CONSTRAINT "Tariff_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "B2BRequest" ADD CONSTRAINT "B2BRequest_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+176 -9
View File
@@ -1,5 +1,6 @@
// Moraworld Imports — Schema v0 (Fase 01)
// Moraworld Imports — Schema v0.2 (Fase 01)
// Multi-tenant por tenant_id en todas las tablas de negocio
// Sincronizado con documentacion.html v1.1
generator client {
provider = "prisma-client-js"
@@ -21,6 +22,7 @@ enum UserRole {
SOPORTE
}
/// 11 estados del ciclo de vida de un paquete (doc §08)
enum PackageStatus {
REGISTRADO
EN_TRANSITO_BODEGA
@@ -41,6 +43,37 @@ enum PreAlertStatus {
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 {
@@ -51,10 +84,12 @@ model Tenant {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
suites Suite[]
packages Package[]
preAlerts PreAlert[]
users User[]
suites Suite[]
packages Package[]
preAlerts PreAlert[]
tariffs Tariff[]
b2bRequests B2BRequest[]
}
// ─── Usuarios ────────────────────────────────────────────────
@@ -75,16 +110,33 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
suite Suite?
packages Package[]
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
@@ -105,15 +157,34 @@ model Package {
id String @id @default(cuid())
tenantId String
userId String
trackingId String @unique // EC-YYYYMMDD-XXXXXX
/// 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
@@ -122,6 +193,7 @@ model Package {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
statusHistory PackageStatusHistory[]
preAlert PreAlert?
notifications Notification[]
@@index([tenantId, status])
@@index([userId])
@@ -145,24 +217,118 @@ model PackageStatusHistory {
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?
@@ -172,6 +338,7 @@ model AuditLog {
resourceId String?
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
+216 -12
View File
@@ -1,31 +1,235 @@
import { PrismaClient, UserRole } from "@prisma/client";
/**
* Seed de desarrollo — Moraworld Imports
* Crea datos de prueba para todos los roles documentados en §06 de documentacion.html
*
* Usuarios de prueba:
* super@moraworld.test → SUPER_ADMIN
* admin@moraworld.test → ADMIN_EMPRESA
* bodega@moraworld.test → OPERADOR_BODEGA
* aduanero@moraworld.test → AGENTE_ADUANERO
* cliente@moraworld.test → CLIENTE (con Suite EC-00001)
* soporte@moraworld.test → SOPORTE
*
* NOTA: Las contraseñas son placeholders (bcrypt se implementa en Fase 1 — auth).
*/
import { PrismaClient, UserRole, PackageStatus, SenaeCategory } from "@prisma/client";
const prisma = new PrismaClient();
// Placeholder hash (en Fase 1 se reemplaza por bcrypt.hash("Test1234!", 10))
const PLACEHOLDER_HASH = "$2b$10$PLACEHOLDER_CHANGE_IN_PHASE_1_AUTH_MODULE";
async function main() {
// ─── Tenant ─────────────────────────────────────────────────
const tenant = await prisma.tenant.upsert({
where: { slug: "moraworld" },
update: {},
update: { name: "Moraworld Imports", isActive: true },
create: {
slug: "moraworld",
name: "Moraworld Imports",
isActive: true,
},
});
console.log(`✓ Tenant: ${tenant.slug} (id: ${tenant.id})`);
console.log("✓ Tenant:", tenant.slug);
const suiteCode = "EC-00001";
const demoEmail = "demo@moraworld.test";
const existing = await prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: demoEmail } },
// ─── Tarifa base (doc §15) ────────────────────────────────
await prisma.tariff.upsert({
where: { tenantId: tenant.id },
update: {},
create: {
tenantId: tenant.id,
pricePerLb: 3.50,
insurancePct: 0.02,
fodinfaPct: 0.005,
ivaPct: 0.15,
max4x4Value: 400,
max4x4WeightKg: 4,
max4x4PerYear: 4,
},
});
console.log("✓ Tarifa base configurada ($3.50/lb, 2% seguro, 0.5% FODINFA, 15% IVA)");
if (!existing) {
console.log(" Usuario demo se creará en Fase 1 (auth con bcrypt)");
// ─── Usuarios de prueba (uno por cada rol del §06) ────────
const usersToSeed: Array<{
email: string;
role: UserRole;
firstName: string;
lastName: string;
phone?: string;
}> = [
{ email: "super@moraworld.test", role: UserRole.SUPER_ADMIN, firstName: "System", lastName: "Admin" },
{ email: "admin@moraworld.test", role: UserRole.ADMIN_EMPRESA, firstName: "Carlos", lastName: "Mora" },
{ email: "bodega@moraworld.test", role: UserRole.OPERADOR_BODEGA, firstName: "James", lastName: "Wilson", phone: "+1-555-0100" },
{ email: "aduanero@moraworld.test", role: UserRole.AGENTE_ADUANERO, firstName: "Sofía", lastName: "Estrella" },
{ email: "cliente@moraworld.test", role: UserRole.CLIENTE, firstName: "Andrés", lastName: "Gutiérrez", phone: "+593-99-999-0001" },
{ email: "soporte@moraworld.test", role: UserRole.SOPORTE, firstName: "Laura", lastName: "Vásquez" },
];
const createdUsers: Record<string, string> = {};
for (const u of usersToSeed) {
const existing = await prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: u.email } },
});
if (!existing) {
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
email: u.email,
passwordHash: PLACEHOLDER_HASH,
firstName: u.firstName,
lastName: u.lastName,
phone: u.phone ?? null,
role: u.role,
mfaEnabled: false,
isActive: true,
},
});
createdUsers[u.role] = user.id;
console.log(`✓ Usuario creado: ${u.email} (${u.role})`);
} else {
createdUsers[u.role] = existing.id;
console.log(`→ Usuario ya existe: ${u.email} (${u.role})`);
}
}
console.log("✓ Seed completado");
// ─── Suite para el cliente demo ───────────────────────────
// Formato: EC-00001 (doc §02 y §09)
const clientId = createdUsers[UserRole.CLIENTE];
if (clientId) {
const existingSuite = await prisma.suite.findUnique({ where: { userId: clientId } });
if (!existingSuite) {
await prisma.suite.create({
data: {
tenantId: tenant.id,
userId: clientId,
code: "EC-00001",
},
});
console.log("✓ Suite EC-00001 asignada a cliente demo");
} else {
console.log(`→ Suite ya existe: ${existingSuite.code}`);
}
}
// ─── Paquete demo con historial de estados ────────────────
const existingPkg = await prisma.package.findUnique({
where: { trackingId: "EC-20260506-000001" },
});
if (!existingPkg && clientId) {
const pkg = await prisma.package.create({
data: {
tenantId: tenant.id,
userId: clientId,
trackingId: "EC-20260506-000001",
status: PackageStatus.VERIFICADO,
description: "Tenis Nike Air Max 270 (demo seed)",
store: "Amazon",
declaredValue: 150.00,
declaredWeight: 2.5,
actualWeight: 2.6,
lengthCm: 32,
widthCm: 22,
heightCm: 14,
hasDiscrepancy: false,
vendorTracking: "1Z999AA10123456784",
productUrl: "https://www.amazon.com/dp/DEMO",
photos: [],
senaeCategory: SenaeCategory.REGIMEN_4X4,
paidAt: new Date("2026-05-06T10:00:00.000Z"),
},
});
// Historial de estados hasta VERIFICADO
const historialEstados: Array<{ status: PackageStatus; note: string; date: string }> = [
{ status: PackageStatus.REGISTRADO, note: "Paquete registrado por el cliente", date: "2026-05-06T10:00:00Z" },
{ status: PackageStatus.EN_TRANSITO_BODEGA, note: "Amazon confirmó despacho", date: "2026-05-07T09:00:00Z" },
{ status: PackageStatus.RECIBIDO_BODEGA, note: "Recibido en 150 N Day St, NJ", date: "2026-05-09T14:30:00Z" },
{ status: PackageStatus.EN_VERIFICACION, note: "Operador revisando paquete", date: "2026-05-09T15:00:00Z" },
{ status: PackageStatus.VERIFICADO, note: "Peso real: 2.6 lbs. Sin discrepancia.", date: "2026-05-09T15:45:00Z" },
];
for (const h of historialEstados) {
await prisma.packageStatusHistory.create({
data: {
packageId: pkg.id,
status: h.status,
note: h.note,
createdBy: clientId,
createdAt: new Date(h.date),
},
});
}
console.log(`✓ Paquete demo creado: ${pkg.trackingId} (estado: ${pkg.status})`);
} else {
console.log("→ Paquete demo ya existe");
}
// ─── Pre-alerta demo ──────────────────────────────────────
if (clientId) {
const existingPreAlert = await prisma.preAlert.findFirst({
where: { tenantId: tenant.id, userId: clientId },
});
if (!existingPreAlert) {
await prisma.preAlert.create({
data: {
tenantId: tenant.id,
userId: clientId,
store: "eBay",
vendorTracking: "9400111899223397992682",
description: "Audífonos Sony WH-1000XM5 (demo pre-alerta)",
declaredValue: 280.00,
estimatedArrival: new Date("2026-05-20T00:00:00.000Z"),
status: "PENDIENTE",
},
});
console.log("✓ Pre-alerta demo creada");
} else {
console.log("→ Pre-alerta demo ya existe");
}
}
// ─── Solicitud B2B demo (doc §13) ─────────────────────────
const existingB2B = await prisma.b2BRequest.findUnique({
where: { trackingId: "B2B-20260506-000001" },
});
if (!existingB2B) {
await prisma.b2BRequest.create({
data: {
tenantId: tenant.id,
trackingId: "B2B-20260506-000001",
contactName: "Roberto Andrade",
contactEmail: "roberto@importadora.ec",
contactPhone: "+593-98-765-4321",
companyName: "Importadora Andrade S.A.",
merchandiseType: "Calzado deportivo",
description: "1,000 pares de zapatos deportivos Nike (demo B2B)",
estimatedWeightKg: 1500,
pallets: 4,
commercialValue: 18000.00,
originCity: "Miami, FL",
requiresInen: true,
status: "PENDIENTE",
},
});
console.log("✓ Solicitud B2B demo creada: B2B-20260506-000001");
} else {
console.log("→ Solicitud B2B demo ya existe");
}
console.log("\n✅ Seed completado correctamente.");
console.log("\n📋 Usuarios de prueba:");
console.log(" super@moraworld.test → SUPER_ADMIN");
console.log(" admin@moraworld.test → ADMIN_EMPRESA");
console.log(" bodega@moraworld.test → OPERADOR_BODEGA");
console.log(" aduanero@moraworld.test → AGENTE_ADUANERO");
console.log(" cliente@moraworld.test → CLIENTE (Suite EC-00001)");
console.log(" soporte@moraworld.test → SOPORTE");
console.log("\n ⚠️ Contraseñas: placeholder — implementar bcrypt en Fase 1 (auth).");
}
main()