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:
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Moraworld Imports — Schema v0 (Fase 0–1)
|
||||
// Moraworld Imports — Schema v0.2 (Fase 0–1)
|
||||
// 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])
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user