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:
+98
-10
@@ -1,12 +1,16 @@
|
||||
# ─── Base de datos ───
|
||||
# Opción A — Coolify (recomendado si ya tienes Postgres ahí):
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Moraworld Imports — Variables de entorno
|
||||
# Sincronizado con documentacion.html v1.1
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ─── Base de datos ───────────────────────────────────────────
|
||||
# Opción A — Coolify (PostgreSQL gestionado):
|
||||
# DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/NOMBRE_BD?schema=moraworld"
|
||||
# Nota: en Postgres 15+ el schema "public" suele no permitir CREATE; usamos "moraworld".
|
||||
#
|
||||
# Opción B — Local con docker compose:
|
||||
DATABASE_URL="postgresql://moraworld:moraworld_dev@localhost:5432/moraworld?schema=public"
|
||||
|
||||
# ─── Redis ───
|
||||
# ─── Redis (colas de notificaciones, sesiones) ───────────────
|
||||
# Opción A — Coolify:
|
||||
# REDIS_URL="redis://:PASSWORD@HOST:6379"
|
||||
# REDIS_URL="rediss://:PASSWORD@HOST:6380" # si TLS
|
||||
@@ -14,31 +18,115 @@ DATABASE_URL="postgresql://moraworld:moraworld_dev@localhost:5432/moraworld?sche
|
||||
# Opción B — Local:
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# ─── API (NestJS) ───
|
||||
# ─── API (NestJS) ────────────────────────────────────────────
|
||||
API_PORT=3001
|
||||
API_URL="http://localhost:3001"
|
||||
JWT_SECRET="change-me-in-production-min-32-chars-long"
|
||||
|
||||
# JWT — Autenticación OAuth 2.0 + JWT + MFA (doc §05)
|
||||
JWT_SECRET="change-me-in-production-min-32-chars-long-abc123"
|
||||
JWT_EXPIRES_IN="15m"
|
||||
JWT_REFRESH_EXPIRES_IN="7d"
|
||||
|
||||
# ─── Web (Next.js) ───
|
||||
# ─── Web (Next.js) ───────────────────────────────────────────
|
||||
WEB_PORT=3000
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||
|
||||
# ─── CORS (coma separada; en Coolify usar dominio real) ───
|
||||
# ─── CORS (coma separada; en Coolify usar dominio real) ──────
|
||||
CORS_ORIGINS="http://localhost:3000"
|
||||
|
||||
# ─── Moraworld (negocio) ───
|
||||
# ─── Moraworld — Bodega NJ (doc §02) ─────────────────────────
|
||||
# Dirección: 150 N Day St, City of Orange, NJ 07050, EE.UU.
|
||||
WAREHOUSE_ADDRESS_STREET="150 N Day St"
|
||||
WAREHOUSE_ADDRESS_CITY="City of Orange"
|
||||
WAREHOUSE_ADDRESS_STATE="NJ"
|
||||
WAREHOUSE_ADDRESS_ZIP="07050"
|
||||
WAREHOUSE_ADDRESS_COUNTRY="US"
|
||||
|
||||
# ─── S3 / archivos (opcional Fase 1; MinIO local en docker-compose) ───
|
||||
# ─── Amazon SP-API (doc §14 — registro por enlace) ───────────
|
||||
# https://developer-docs.amazon.com/sp-api/docs
|
||||
AMAZON_SP_CLIENT_ID=""
|
||||
AMAZON_SP_CLIENT_SECRET=""
|
||||
AMAZON_SP_REFRESH_TOKEN=""
|
||||
AMAZON_SP_MARKETPLACE_ID="ATVPDKIKX0DER" # US marketplace
|
||||
|
||||
# ─── WebService SENAE (doc §11 — declaración aduanera) ───────
|
||||
# SOAP / REST — Portal electrónico aduana Ecuador
|
||||
SENAE_ENDPOINT="https://declaraciones.aduana.gob.ec/wsDeclaraciones"
|
||||
SENAE_USERNAME=""
|
||||
SENAE_PASSWORD=""
|
||||
SENAE_CERT_PATH="" # Certificado digital PEM
|
||||
SENAE_CERT_PASSPHRASE=""
|
||||
|
||||
# ─── Pasarela de pagos (doc §09 paso 8 / §18) ────────────────
|
||||
# Stripe (internacional)
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
|
||||
|
||||
# PayPhone (Ecuador)
|
||||
PAYPHONE_APP_ID=""
|
||||
PAYPHONE_TOKEN=""
|
||||
PAYPHONE_STORE_ID=""
|
||||
|
||||
# ─── WhatsApp Business API (doc §16 / §18) ───────────────────
|
||||
# Notificaciones automáticas + botón flotante de contacto
|
||||
WHATSAPP_API_URL="https://graph.facebook.com/v19.0"
|
||||
WHATSAPP_PHONE_NUMBER_ID=""
|
||||
WHATSAPP_ACCESS_TOKEN=""
|
||||
# Número NJ (operaciones) y Cuenca (aduana) — doc §21
|
||||
WHATSAPP_NJ_NUMBER="+15550000001"
|
||||
WHATSAPP_CUENCA_NUMBER="+5939900000001"
|
||||
|
||||
# ─── Email (doc §16) ─────────────────────────────────────────
|
||||
# SendGrid
|
||||
SENDGRID_API_KEY=""
|
||||
EMAIL_FROM="noreply@moraworldimports.com"
|
||||
|
||||
# AWS SES (alternativa)
|
||||
# AWS_SES_REGION="us-east-1"
|
||||
# AWS_SES_ACCESS_KEY=""
|
||||
# AWS_SES_SECRET_KEY=""
|
||||
|
||||
# ─── SMS Gateway (doc §16) ───────────────────────────────────
|
||||
SMS_PROVIDER="twilio" # twilio | vonage
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_PHONE_NUMBER=""
|
||||
|
||||
# ─── Couriers internacionales (doc §18) ──────────────────────
|
||||
# FedEx
|
||||
FEDEX_CLIENT_ID=""
|
||||
FEDEX_CLIENT_SECRET=""
|
||||
FEDEX_ACCOUNT_NUMBER=""
|
||||
|
||||
# DHL
|
||||
DHL_API_KEY=""
|
||||
DHL_API_SECRET=""
|
||||
|
||||
# UPS
|
||||
UPS_CLIENT_ID=""
|
||||
UPS_CLIENT_SECRET=""
|
||||
|
||||
# ─── S3 / Almacenamiento de archivos (doc §05) ───────────────
|
||||
# Fotos de paquetes, facturas, documentos SENAE
|
||||
# AWS S3
|
||||
S3_BUCKET="moraworld-files"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ACCESS_KEY=""
|
||||
S3_SECRET_KEY=""
|
||||
|
||||
# MinIO local (docker-compose — desarrollo):
|
||||
# S3_ENDPOINT="http://localhost:9000"
|
||||
# S3_BUCKET="moraworld"
|
||||
# S3_ACCESS_KEY="minioadmin"
|
||||
# S3_SECRET_KEY="minioadmin"
|
||||
# S3_REGION="us-east-1"
|
||||
# S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# ─── SIEM / Monitoreo (doc §05 ISO A.12) ─────────────────────
|
||||
# Datadog / New Relic / Sentry
|
||||
SENTRY_DSN=""
|
||||
# DATADOG_API_KEY=""
|
||||
|
||||
# ─── Entorno ─────────────────────────────────────────────────
|
||||
NODE_ENV="development" # development | staging | production
|
||||
|
||||
+28
-1
@@ -7,7 +7,11 @@
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:ci": "jest --ci --coverage --forceExit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moraworld/database": "workspace:*",
|
||||
@@ -21,8 +25,31 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.7",
|
||||
"@nestjs/schematics": "^11.0.5",
|
||||
"@nestjs/testing": "^11.1.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.21",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.3.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!**/main.ts"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 80,
|
||||
"functions": 80,
|
||||
"lines": 80,
|
||||
"statements": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { CalculatorModule } from "./calculator/calculator.module";
|
||||
import { TrackingModule } from "./tracking/tracking.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -11,6 +13,8 @@ import { PrismaModule } from "./prisma/prisma.module";
|
||||
}),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
CalculatorModule,
|
||||
TrackingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Controller, Get, Query, BadRequestException } from "@nestjs/common";
|
||||
import { CalculatorService } from "./calculator.service";
|
||||
import { SenaeCategory } from "../common/utils/calculator.util";
|
||||
|
||||
@Controller("calculator")
|
||||
export class CalculatorController {
|
||||
constructor(private readonly calculatorService: CalculatorService) {}
|
||||
|
||||
/**
|
||||
* GET /api/calculator
|
||||
* Calcula el costo de envío. Disponible sin autenticación (doc §15).
|
||||
*
|
||||
* Query params:
|
||||
* value - Valor declarado en USD (requerido)
|
||||
* weight - Peso en libras (requerido)
|
||||
* length - Largo en cm (opcional, para peso volumétrico)
|
||||
* width - Ancho en cm
|
||||
* height - Alto en cm
|
||||
* category - REGIMEN_4X4 | CATEGORIA_B | CATEGORIA_C | CATEGORIA_D (opcional)
|
||||
* shipments - Envíos ya realizados este año (opcional, para 4×4)
|
||||
*/
|
||||
@Get()
|
||||
calculate(
|
||||
@Query("value") value: string,
|
||||
@Query("weight") weight: string,
|
||||
@Query("length") length?: string,
|
||||
@Query("width") width?: string,
|
||||
@Query("height") height?: string,
|
||||
@Query("category") category?: string,
|
||||
@Query("shipments") shipments?: string,
|
||||
) {
|
||||
const declaredValueUsd = parseFloat(value);
|
||||
const weightLbs = parseFloat(weight);
|
||||
|
||||
if (isNaN(declaredValueUsd) || declaredValueUsd < 0) {
|
||||
throw new BadRequestException("El parámetro 'value' debe ser un número >= 0.");
|
||||
}
|
||||
if (isNaN(weightLbs) || weightLbs <= 0) {
|
||||
throw new BadRequestException("El parámetro 'weight' debe ser un número > 0.");
|
||||
}
|
||||
|
||||
let parsedCategory: SenaeCategory | undefined;
|
||||
if (category) {
|
||||
if (!Object.values(SenaeCategory).includes(category as SenaeCategory)) {
|
||||
throw new BadRequestException(
|
||||
`Categoría inválida. Valores válidos: ${Object.values(SenaeCategory).join(", ")}`,
|
||||
);
|
||||
}
|
||||
parsedCategory = category as SenaeCategory;
|
||||
}
|
||||
|
||||
return this.calculatorService.calculate({
|
||||
declaredValueUsd,
|
||||
weightLbs,
|
||||
lengthCm: length ? parseFloat(length) : undefined,
|
||||
widthCm: width ? parseFloat(width) : undefined,
|
||||
heightCm: height ? parseFloat(height) : undefined,
|
||||
category: parsedCategory,
|
||||
shipmentsThisYear: shipments ? parseInt(shipments, 10) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CalculatorController } from "./calculator.controller";
|
||||
import { CalculatorService } from "./calculator.service";
|
||||
|
||||
@Module({
|
||||
controllers: [CalculatorController],
|
||||
providers: [CalculatorService],
|
||||
exports: [CalculatorService],
|
||||
})
|
||||
export class CalculatorModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
CalculatorInput,
|
||||
CalculatorResult,
|
||||
SenaeCategory,
|
||||
calculateShipping,
|
||||
qualifiesFor4x4,
|
||||
} from "../common/utils/calculator.util";
|
||||
|
||||
export interface CalcRequest {
|
||||
declaredValueUsd: number;
|
||||
weightLbs: number;
|
||||
lengthCm?: number;
|
||||
widthCm?: number;
|
||||
heightCm?: number;
|
||||
category?: SenaeCategory;
|
||||
shipmentsThisYear?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CalculatorService {
|
||||
/**
|
||||
* Calcula el costo de envío con desglose SENAE.
|
||||
* Si no se especifica categoría, determina automáticamente si aplica 4×4.
|
||||
*/
|
||||
calculate(req: CalcRequest): CalculatorResult {
|
||||
const category = req.category ?? this.autoCategory(
|
||||
req.declaredValueUsd,
|
||||
req.weightLbs,
|
||||
req.shipmentsThisYear ?? 0,
|
||||
);
|
||||
|
||||
const input: CalculatorInput = {
|
||||
declaredValueUsd: req.declaredValueUsd,
|
||||
weightLbs: req.weightLbs,
|
||||
lengthCm: req.lengthCm,
|
||||
widthCm: req.widthCm,
|
||||
heightCm: req.heightCm,
|
||||
category,
|
||||
};
|
||||
|
||||
return calculateShipping(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina la categoría mínima según los límites 4×4.
|
||||
* Si no aplica, retorna CATEGORIA_B como base (el usuario puede cambiarla).
|
||||
*/
|
||||
autoCategory(
|
||||
valueUsd: number,
|
||||
weightLbs: number,
|
||||
shipmentsThisYear: number,
|
||||
): SenaeCategory {
|
||||
if (qualifiesFor4x4(valueUsd, weightLbs, shipmentsThisYear)) {
|
||||
return SenaeCategory.REGIMEN_4X4;
|
||||
}
|
||||
return SenaeCategory.CATEGORIA_B;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CalculatorController } from "./calculator.controller";
|
||||
import { CalculatorService } from "./calculator.service";
|
||||
import { SenaeCategory } from "../common/utils/calculator.util";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
describe("CalculatorController", () => {
|
||||
let controller: CalculatorController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CalculatorController],
|
||||
providers: [CalculatorService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CalculatorController>(CalculatorController);
|
||||
});
|
||||
|
||||
it("debe estar definido", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it("calcula correctamente con parámetros mínimos", () => {
|
||||
const result = controller.calculate("150", "2");
|
||||
expect(result.total).toBeGreaterThan(0);
|
||||
expect(result.flete).toBe(7); // 2 × 3.50
|
||||
});
|
||||
|
||||
it("acepta categoría explícita", () => {
|
||||
const result = controller.calculate("500", "5", undefined, undefined, undefined, SenaeCategory.CATEGORIA_C);
|
||||
expect(result.category).toBe(SenaeCategory.CATEGORIA_C);
|
||||
expect(result.arancelRate).toBe(0.20);
|
||||
});
|
||||
|
||||
it("lanza BadRequestException si value no es número", () => {
|
||||
expect(() => controller.calculate("abc", "2")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("lanza BadRequestException si weight es 0", () => {
|
||||
expect(() => controller.calculate("100", "0")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("lanza BadRequestException si value es negativo", () => {
|
||||
expect(() => controller.calculate("-1", "2")).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("lanza BadRequestException con categoría inválida", () => {
|
||||
expect(() =>
|
||||
controller.calculate("100", "2", undefined, undefined, undefined, "CATEGORIA_Z"),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("calcula el peso volumétrico cuando se proveen dimensiones", () => {
|
||||
const result = controller.calculate("100", "1", "50", "40", "30");
|
||||
// 50×40×30 / 139 ≈ 43.17 lbs → mayor que 1 lb
|
||||
expect(result.finalWeightLbs).toBeGreaterThan(1);
|
||||
expect(result.volumetricWeightLbs).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalculatorService", () => {
|
||||
let service: CalculatorService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CalculatorService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CalculatorService>(CalculatorService);
|
||||
});
|
||||
|
||||
it("autoCategory retorna REGIMEN_4X4 si califica", () => {
|
||||
const cat = service.autoCategory(200, 2, 0);
|
||||
expect(cat).toBe(SenaeCategory.REGIMEN_4X4);
|
||||
});
|
||||
|
||||
it("autoCategory retorna CATEGORIA_B si no califica 4×4", () => {
|
||||
const cat = service.autoCategory(500, 10, 0);
|
||||
expect(cat).toBe(SenaeCategory.CATEGORIA_B);
|
||||
});
|
||||
|
||||
it("calculate sin categoría asigna automáticamente", () => {
|
||||
const result = service.calculate({ declaredValueUsd: 200, weightLbs: 2 });
|
||||
expect(result.category).toBe(SenaeCategory.REGIMEN_4X4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
calculateShipping,
|
||||
calcVolumetricWeight,
|
||||
qualifiesFor4x4,
|
||||
SenaeCategory,
|
||||
SENAE_TARIFF_RATES,
|
||||
} from "./calculator.util";
|
||||
|
||||
/**
|
||||
* Tests del servicio de calculadora SENAE.
|
||||
* Validan exactamente las fórmulas documentadas en §15 de documentacion.html
|
||||
*/
|
||||
describe("Calculator — Fórmulas SENAE (doc §15)", () => {
|
||||
// ─── Peso volumétrico ─────────────────────────────────────
|
||||
|
||||
describe("calcVolumetricWeight", () => {
|
||||
it("calcula peso volumétrico correctamente: (L×A×H)/139", () => {
|
||||
// 30 × 20 × 15 = 9000 / 139 ≈ 64.748 g → 0.0648 lbs (en lbs)
|
||||
const result = calcVolumetricWeight(30, 20, 15);
|
||||
expect(result).toBeCloseTo(9000 / 139, 3);
|
||||
});
|
||||
|
||||
it("retorna null si faltan dimensiones", () => {
|
||||
expect(calcVolumetricWeight(30, 20, undefined)).toBeNull();
|
||||
expect(calcVolumetricWeight(undefined, undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("retorna null si alguna dimensión es 0 o negativa", () => {
|
||||
expect(calcVolumetricWeight(0, 20, 15)).toBeNull();
|
||||
expect(calcVolumetricWeight(30, -5, 15)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Régimen 4×4 ──────────────────────────────────────────
|
||||
|
||||
describe("qualifiesFor4x4", () => {
|
||||
it("califica si valor ≤ $400 y peso ≤ 4 kg", () => {
|
||||
// 2 lbs = 0.907 kg → bien dentro del límite
|
||||
expect(qualifiesFor4x4(200, 2, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("no califica si valor > $400", () => {
|
||||
expect(qualifiesFor4x4(401, 2, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("no califica si peso > 4 kg (≈ 8.82 lbs)", () => {
|
||||
// 9 lbs = 4.08 kg
|
||||
expect(qualifiesFor4x4(200, 9, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("no califica si ya usó 4 envíos en el año", () => {
|
||||
expect(qualifiesFor4x4(200, 2, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it("califica si aún tiene envíos disponibles (3 de 4)", () => {
|
||||
expect(qualifiesFor4x4(200, 2, 3)).toBe(true);
|
||||
});
|
||||
|
||||
it("califica exactamente en $400 y 4 kg (límite exacto)", () => {
|
||||
const weightLbs = 4 / 0.453592; // exactamente 4 kg en lbs
|
||||
expect(qualifiesFor4x4(400, weightLbs, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tasas de arancel por categoría ───────────────────────
|
||||
|
||||
describe("SENAE_TARIFF_RATES", () => {
|
||||
it("REGIMEN_4X4 tiene tasa 0%", () => {
|
||||
expect(SENAE_TARIFF_RATES[SenaeCategory.REGIMEN_4X4]).toBe(0);
|
||||
});
|
||||
|
||||
it("CATEGORIA_B tiene tasa 10%", () => {
|
||||
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_B]).toBe(0.10);
|
||||
});
|
||||
|
||||
it("CATEGORIA_C tiene tasa 20%", () => {
|
||||
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_C]).toBe(0.20);
|
||||
});
|
||||
|
||||
it("CATEGORIA_D tiene tasa 10% (base)", () => {
|
||||
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_D]).toBe(0.10);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── calculateShipping — Régimen 4×4 ──────────────────────
|
||||
|
||||
describe("calculateShipping — Régimen 4×4 (0% arancel)", () => {
|
||||
const base = {
|
||||
declaredValueUsd: 150,
|
||||
weightLbs: 2,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
};
|
||||
|
||||
it("flete = peso × $3.50", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.flete).toBe(2 * 3.5); // 7.00
|
||||
});
|
||||
|
||||
it("seguro = valor × 2%", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.seguro).toBe(Math.round(150 * 0.02 * 100) / 100); // 3.00
|
||||
});
|
||||
|
||||
it("FODINFA = valor × 0.5%", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.fodinfa).toBe(Math.round(150 * 0.005 * 100) / 100); // 0.75
|
||||
});
|
||||
|
||||
it("arancel = 0 para régimen 4×4", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.arancel).toBe(0);
|
||||
});
|
||||
|
||||
it("IVA = (valor + FODINFA + arancel) × 15%", () => {
|
||||
const r = calculateShipping(base);
|
||||
// IVA = (150 + 0.75 + 0) × 0.15 = 150.75 × 0.15 = 22.6125 → 22.61
|
||||
const expected = Math.round((150 + 0.75 + 0) * 0.15 * 100) / 100;
|
||||
expect(r.iva).toBe(expected);
|
||||
});
|
||||
|
||||
it("TOTAL = Flete + Seguro + FODINFA + Arancel + IVA", () => {
|
||||
const r = calculateShipping(base);
|
||||
const expected = Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100;
|
||||
expect(r.total).toBe(expected);
|
||||
});
|
||||
|
||||
it("is4x4Regime = true", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.is4x4Regime).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── calculateShipping — Categoría B (10%) ────────────────
|
||||
|
||||
describe("calculateShipping — Categoría B (10% arancel)", () => {
|
||||
const base = {
|
||||
declaredValueUsd: 500,
|
||||
weightLbs: 5,
|
||||
category: SenaeCategory.CATEGORIA_B,
|
||||
};
|
||||
|
||||
it("arancel = valor × 10%", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.arancel).toBe(Math.round(500 * 0.10 * 100) / 100); // 50.00
|
||||
});
|
||||
|
||||
it("is4x4Regime = false", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.is4x4Regime).toBe(false);
|
||||
});
|
||||
|
||||
it("total incluye todos los componentes", () => {
|
||||
const r = calculateShipping(base);
|
||||
expect(r.total).toBe(
|
||||
Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── calculateShipping — Categoría C (20%) ────────────────
|
||||
|
||||
describe("calculateShipping — Categoría C (20% arancel)", () => {
|
||||
it("arancel = valor × 20%", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 300,
|
||||
weightLbs: 3,
|
||||
category: SenaeCategory.CATEGORIA_C,
|
||||
});
|
||||
expect(r.arancel).toBe(Math.round(300 * 0.20 * 100) / 100); // 60.00
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Peso volumétrico activo ───────────────────────────────
|
||||
|
||||
describe("calculateShipping — Peso volumétrico", () => {
|
||||
it("usa el peso volumétrico si es mayor al real", () => {
|
||||
// Caja grande liviana: 100 × 80 × 60 cm = 480000 / 139 ≈ 3453.2 lbs?
|
||||
// No... 100×80×60 / 139 = 3453.2 → ese es el peso volumétrico en alguna unidad
|
||||
// Pero el peso real es 1 lb → volumétrico es mucho mayor
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 1,
|
||||
lengthCm: 100,
|
||||
widthCm: 80,
|
||||
heightCm: 60,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
});
|
||||
// volumétrico = 100*80*60/139 = 3453.2...
|
||||
expect(r.finalWeightLbs).toBeGreaterThan(1);
|
||||
expect(r.volumetricWeightLbs).not.toBeNull();
|
||||
});
|
||||
|
||||
it("usa el peso real si es mayor al volumétrico", () => {
|
||||
// Caja pequeña pesada: 10×10×10 = 1000/139 ≈ 7.19 lbs, peso real = 20 lbs
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 20,
|
||||
lengthCm: 10,
|
||||
widthCm: 10,
|
||||
heightCm: 10,
|
||||
category: SenaeCategory.CATEGORIA_B,
|
||||
});
|
||||
expect(r.finalWeightLbs).toBe(20);
|
||||
});
|
||||
|
||||
it("sin dimensiones, finalWeightLbs = peso real", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 5,
|
||||
category: SenaeCategory.CATEGORIA_B,
|
||||
});
|
||||
expect(r.finalWeightLbs).toBe(5);
|
||||
expect(r.volumetricWeightLbs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Validaciones de entrada ──────────────────────────────
|
||||
|
||||
describe("calculateShipping — validaciones", () => {
|
||||
it("lanza error si valor declarado es negativo", () => {
|
||||
expect(() =>
|
||||
calculateShipping({
|
||||
declaredValueUsd: -1,
|
||||
weightLbs: 2,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
}),
|
||||
).toThrow("El valor declarado no puede ser negativo.");
|
||||
});
|
||||
|
||||
it("lanza error si el peso es 0 o negativo", () => {
|
||||
expect(() =>
|
||||
calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 0,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
}),
|
||||
).toThrow("El peso debe ser mayor a 0.");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Breakdown ────────────────────────────────────────────
|
||||
|
||||
describe("breakdown", () => {
|
||||
it("incluye 6 items (Flete, Seguro, FODINFA, Arancel, IVA, TOTAL)", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 200,
|
||||
weightLbs: 3,
|
||||
category: SenaeCategory.CATEGORIA_B,
|
||||
});
|
||||
expect(r.breakdown.items).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("el último item (TOTAL) tiene isHighlighted=true", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 200,
|
||||
weightLbs: 3,
|
||||
category: SenaeCategory.CATEGORIA_B,
|
||||
});
|
||||
const last = r.breakdown.items[r.breakdown.items.length - 1];
|
||||
expect(last.isHighlighted).toBe(true);
|
||||
expect(last.label).toBe("TOTAL");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tarifa configurable ──────────────────────────────────
|
||||
|
||||
describe("tarifas configurables", () => {
|
||||
it("acepta pricePerLb personalizado", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 2,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
pricePerLb: 4.0,
|
||||
});
|
||||
expect(r.flete).toBe(8.0);
|
||||
});
|
||||
|
||||
it("acepta insurancePct personalizado", () => {
|
||||
const r = calculateShipping({
|
||||
declaredValueUsd: 100,
|
||||
weightLbs: 2,
|
||||
category: SenaeCategory.REGIMEN_4X4,
|
||||
insurancePct: 0.03,
|
||||
});
|
||||
expect(r.seguro).toBe(3.0); // 100 × 3%
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Calculadora SENAE — Lógica exacta de la documentación §15
|
||||
*
|
||||
* Categorías SENAE:
|
||||
* REGIMEN_4X4 → 0% Hasta $400, 4 kg, 4 envíos/año — proceso simplificado
|
||||
* CATEGORIA_B → 10% Bienes de consumo general
|
||||
* CATEGORIA_C → 20% Textiles, calzado, artículos del hogar
|
||||
* CATEGORIA_D → 0-15% Electrónicos — se usa 10% como base (puede variar)
|
||||
*
|
||||
* Fórmulas (doc §15):
|
||||
* Peso_volumétrico = (largo_cm × ancho_cm × alto_cm) / 139
|
||||
* Peso_final = MAX(peso_real_lbs, Peso_volumétrico)
|
||||
* Flete = Peso_final × $3.50 (configurable)
|
||||
* Seguro = valor_declarado × 0.02
|
||||
* FODINFA = valor_declarado × 0.005
|
||||
* Arancel = valor_declarado × % categoría
|
||||
* IVA = (valor_declarado + FODINFA + Arancel) × 0.15
|
||||
* TOTAL = Flete + Seguro + FODINFA + Arancel + IVA
|
||||
*/
|
||||
|
||||
export enum SenaeCategory {
|
||||
REGIMEN_4X4 = "REGIMEN_4X4",
|
||||
CATEGORIA_B = "CATEGORIA_B",
|
||||
CATEGORIA_C = "CATEGORIA_C",
|
||||
CATEGORIA_D = "CATEGORIA_D",
|
||||
}
|
||||
|
||||
export const SENAE_TARIFF_RATES: Record<SenaeCategory, number> = {
|
||||
[SenaeCategory.REGIMEN_4X4]: 0,
|
||||
[SenaeCategory.CATEGORIA_B]: 0.10,
|
||||
[SenaeCategory.CATEGORIA_C]: 0.20,
|
||||
[SenaeCategory.CATEGORIA_D]: 0.10, // base; puede ajustarse por subpartida
|
||||
};
|
||||
|
||||
export interface CalculatorInput {
|
||||
/** Valor declarado del producto en USD */
|
||||
declaredValueUsd: number;
|
||||
/** Peso real en libras */
|
||||
weightLbs: number;
|
||||
/** Largo en cm (para peso volumétrico, opcional) */
|
||||
lengthCm?: number;
|
||||
/** Ancho en cm */
|
||||
widthCm?: number;
|
||||
/** Alto en cm */
|
||||
heightCm?: number;
|
||||
/** Categoría SENAE del producto */
|
||||
category: SenaeCategory;
|
||||
/** Precio por libra en USD (default: 3.50) */
|
||||
pricePerLb?: number;
|
||||
/** Porcentaje de seguro (default: 0.02 = 2%) */
|
||||
insurancePct?: number;
|
||||
/** FODINFA (default: 0.005 = 0.5%) */
|
||||
fodinfaPct?: number;
|
||||
/** IVA (default: 0.15 = 15%) */
|
||||
ivaPct?: number;
|
||||
}
|
||||
|
||||
export interface CalculatorResult {
|
||||
/** Peso volumétrico calculado en lbs */
|
||||
volumetricWeightLbs: number | null;
|
||||
/** Peso final usado para el cálculo (MAX entre real y volumétrico) */
|
||||
finalWeightLbs: number;
|
||||
/** Costo de flete */
|
||||
flete: number;
|
||||
/** Costo de seguro */
|
||||
seguro: number;
|
||||
/** FODINFA */
|
||||
fodinfa: number;
|
||||
/** Arancel */
|
||||
arancel: number;
|
||||
/** IVA */
|
||||
iva: number;
|
||||
/** Total a pagar */
|
||||
total: number;
|
||||
/** Categoría SENAE aplicada */
|
||||
category: SenaeCategory;
|
||||
/** Tasa de arancel aplicada (ej: 0.10 = 10%) */
|
||||
arancelRate: number;
|
||||
/** Si aplica el régimen 4×4 (sin arancel) */
|
||||
is4x4Regime: boolean;
|
||||
/** Desglose completo en formato display */
|
||||
breakdown: CalculatorBreakdown;
|
||||
}
|
||||
|
||||
export interface CalculatorBreakdown {
|
||||
items: Array<{
|
||||
label: string;
|
||||
formula: string;
|
||||
value: number;
|
||||
isHighlighted?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el peso volumétrico en libras.
|
||||
* Fórmula: (largo × ancho × alto) / 139
|
||||
* Si faltan dimensiones retorna null.
|
||||
*/
|
||||
export function calcVolumetricWeight(
|
||||
lengthCm?: number,
|
||||
widthCm?: number,
|
||||
heightCm?: number,
|
||||
): number | null {
|
||||
if (!lengthCm || !widthCm || !heightCm) return null;
|
||||
if (lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) return null;
|
||||
return (lengthCm * widthCm * heightCm) / 139;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redondea a 2 decimales.
|
||||
*/
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el costo total de envío con desglose SENAE.
|
||||
* Implementa exactamente las fórmulas del documento §15.
|
||||
*/
|
||||
export function calculateShipping(input: CalculatorInput): CalculatorResult {
|
||||
const {
|
||||
declaredValueUsd,
|
||||
weightLbs,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
category,
|
||||
pricePerLb = 3.5,
|
||||
insurancePct = 0.02,
|
||||
fodinfaPct = 0.005,
|
||||
ivaPct = 0.15,
|
||||
} = input;
|
||||
|
||||
if (declaredValueUsd < 0) throw new Error("El valor declarado no puede ser negativo.");
|
||||
if (weightLbs <= 0) throw new Error("El peso debe ser mayor a 0.");
|
||||
|
||||
const arancelRate = SENAE_TARIFF_RATES[category];
|
||||
const is4x4Regime = category === SenaeCategory.REGIMEN_4X4;
|
||||
|
||||
// Peso volumétrico
|
||||
const volumetricWeightLbs = calcVolumetricWeight(lengthCm, widthCm, heightCm);
|
||||
const finalWeightLbs = volumetricWeightLbs !== null
|
||||
? Math.max(weightLbs, volumetricWeightLbs)
|
||||
: weightLbs;
|
||||
|
||||
// Cálculos
|
||||
const flete = round2(finalWeightLbs * pricePerLb);
|
||||
const seguro = round2(declaredValueUsd * insurancePct);
|
||||
const fodinfa = round2(declaredValueUsd * fodinfaPct);
|
||||
const arancel = round2(declaredValueUsd * arancelRate);
|
||||
const iva = round2((declaredValueUsd + fodinfa + arancel) * ivaPct);
|
||||
const total = round2(flete + seguro + fodinfa + arancel + iva);
|
||||
|
||||
const breakdown: CalculatorBreakdown = {
|
||||
items: [
|
||||
{
|
||||
label: "Flete",
|
||||
formula: `${finalWeightLbs.toFixed(2)} lbs × $${pricePerLb.toFixed(2)}/lb`,
|
||||
value: flete,
|
||||
},
|
||||
{
|
||||
label: "Seguro (2%)",
|
||||
formula: `$${declaredValueUsd.toFixed(2)} × ${(insurancePct * 100).toFixed(1)}%`,
|
||||
value: seguro,
|
||||
},
|
||||
{
|
||||
label: "FODINFA (0.5%)",
|
||||
formula: `$${declaredValueUsd.toFixed(2)} × 0.5%`,
|
||||
value: fodinfa,
|
||||
},
|
||||
{
|
||||
label: `Arancel ${category.replace("_", " ")} (${(arancelRate * 100).toFixed(0)}%)`,
|
||||
formula: `$${declaredValueUsd.toFixed(2)} × ${(arancelRate * 100).toFixed(0)}%`,
|
||||
value: arancel,
|
||||
},
|
||||
{
|
||||
label: "IVA (15%)",
|
||||
formula: `($${declaredValueUsd.toFixed(2)} + FODINFA + Arancel) × 15%`,
|
||||
value: iva,
|
||||
},
|
||||
{
|
||||
label: "TOTAL",
|
||||
formula: "Flete + Seguro + FODINFA + Arancel + IVA",
|
||||
value: total,
|
||||
isHighlighted: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
volumetricWeightLbs: volumetricWeightLbs !== null ? round2(volumetricWeightLbs) : null,
|
||||
finalWeightLbs: round2(finalWeightLbs),
|
||||
flete,
|
||||
seguro,
|
||||
fodinfa,
|
||||
arancel,
|
||||
iva,
|
||||
total,
|
||||
category,
|
||||
arancelRate,
|
||||
is4x4Regime,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina si un paquete aplica al régimen 4×4.
|
||||
* Condiciones: valor ≤ $400, peso ≤ 4 kg. (doc §08 estado 06 / §15)
|
||||
*
|
||||
* @param valueUsd Valor declarado en USD
|
||||
* @param weightLbs Peso en libras
|
||||
* @param shipmentsThisYear Número de envíos ya realizados en el año calendario (default 0)
|
||||
* @param max4x4Value Límite de valor (default $400)
|
||||
* @param max4x4WeightKg Límite de peso en kg (default 4 kg)
|
||||
* @param max4x4PerYear Máximo envíos/año (default 4)
|
||||
*/
|
||||
export function qualifiesFor4x4(
|
||||
valueUsd: number,
|
||||
weightLbs: number,
|
||||
shipmentsThisYear = 0,
|
||||
max4x4Value = 400,
|
||||
max4x4WeightKg = 4,
|
||||
max4x4PerYear = 4,
|
||||
): boolean {
|
||||
const weightKg = weightLbs * 0.453592;
|
||||
return (
|
||||
valueUsd <= max4x4Value &&
|
||||
weightKg <= max4x4WeightKg &&
|
||||
shipmentsThisYear < max4x4PerYear
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
generateSuiteCode,
|
||||
parseSuiteCode,
|
||||
buildSuiteAddress,
|
||||
} from "./suite-code.util";
|
||||
|
||||
/**
|
||||
* Tests del generador de Suite (casillero).
|
||||
* Formato documentado en §02 y §09: EC-XXXXX
|
||||
* Dirección: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
|
||||
*/
|
||||
describe("SuiteCode Utils (doc §02 / §09)", () => {
|
||||
describe("generateSuiteCode", () => {
|
||||
it("genera el código EC-00001 para secuencia 1", () => {
|
||||
expect(generateSuiteCode(1)).toBe("EC-00001");
|
||||
});
|
||||
|
||||
it("genera EC-00345 para secuencia 345 (ejemplo de la doc)", () => {
|
||||
expect(generateSuiteCode(345)).toBe("EC-00345");
|
||||
});
|
||||
|
||||
it("genera EC-99999 para el máximo permitido", () => {
|
||||
expect(generateSuiteCode(99999)).toBe("EC-99999");
|
||||
});
|
||||
|
||||
it("rellena con ceros a la izquierda hasta 5 dígitos", () => {
|
||||
expect(generateSuiteCode(7)).toBe("EC-00007");
|
||||
expect(generateSuiteCode(42)).toBe("EC-00042");
|
||||
expect(generateSuiteCode(1234)).toBe("EC-01234");
|
||||
});
|
||||
|
||||
it("siempre empieza con EC-", () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect(generateSuiteCode(i).startsWith("EC-")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("lanza error si la secuencia es 0", () => {
|
||||
expect(() => generateSuiteCode(0)).toThrow();
|
||||
});
|
||||
|
||||
it("lanza error si la secuencia es negativa", () => {
|
||||
expect(() => generateSuiteCode(-1)).toThrow();
|
||||
});
|
||||
|
||||
it("lanza error si la secuencia supera 99999", () => {
|
||||
expect(() => generateSuiteCode(100000)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSuiteCode", () => {
|
||||
it("extrae la secuencia de un código válido", () => {
|
||||
expect(parseSuiteCode("EC-00345")).toBe(345);
|
||||
expect(parseSuiteCode("EC-00001")).toBe(1);
|
||||
expect(parseSuiteCode("EC-99999")).toBe(99999);
|
||||
});
|
||||
|
||||
it("retorna null para formatos inválidos", () => {
|
||||
expect(parseSuiteCode("EC-0034")).toBeNull(); // 4 dígitos
|
||||
expect(parseSuiteCode("US-00345")).toBeNull(); // prefijo incorrecto
|
||||
expect(parseSuiteCode("EC00345")).toBeNull(); // sin guión
|
||||
expect(parseSuiteCode("")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip: generate → parse retorna la misma secuencia", () => {
|
||||
const seq = 12345;
|
||||
const code = generateSuiteCode(seq);
|
||||
expect(parseSuiteCode(code)).toBe(seq);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSuiteAddress", () => {
|
||||
it("construye la dirección completa con la suite (doc §02)", () => {
|
||||
const addr = buildSuiteAddress("EC-00345");
|
||||
expect(addr).toBe(
|
||||
"150 N Day St, Suite EC-00345, City of Orange, NJ 07050, EE.UU.",
|
||||
);
|
||||
});
|
||||
|
||||
it("contiene la dirección exacta de la bodega NJ documentada", () => {
|
||||
const addr = buildSuiteAddress("EC-00001");
|
||||
expect(addr).toContain("150 N Day St");
|
||||
expect(addr).toContain("City of Orange");
|
||||
expect(addr).toContain("NJ 07050");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Genera el código de Suite (casillero) asignado al cliente al registrarse.
|
||||
* Formato: EC-XXXXX (número secuencial 5 dígitos) (doc §02 / §09)
|
||||
* Ejemplo: EC-00345
|
||||
*/
|
||||
export function generateSuiteCode(sequence: number): string {
|
||||
if (sequence < 1 || sequence > 99999) {
|
||||
throw new Error(`Secuencia fuera de rango: ${sequence}. Debe estar entre 1 y 99999.`);
|
||||
}
|
||||
return `EC-${String(sequence).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el número de secuencia de un código de suite.
|
||||
* Retorna null si el formato no es válido.
|
||||
*/
|
||||
export function parseSuiteCode(code: string): number | null {
|
||||
const match = /^EC-(\d{5})$/.exec(code);
|
||||
if (!match) return null;
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye la dirección completa de la Suite para mostrar al cliente.
|
||||
* (doc §02 y §09)
|
||||
*/
|
||||
export function buildSuiteAddress(suiteCode: string): string {
|
||||
return `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
generateTrackingId,
|
||||
isValidTrackingId,
|
||||
} from "./tracking-id.util";
|
||||
|
||||
/**
|
||||
* Tests del Tracking ID.
|
||||
* Formato documentado en §08: EC-YYYYMMDD-XXXXXX
|
||||
*/
|
||||
describe("TrackingId Utils (doc §08)", () => {
|
||||
describe("generateTrackingId", () => {
|
||||
it("genera un ID con el formato EC-YYYYMMDD-XXXXXX", () => {
|
||||
const id = generateTrackingId();
|
||||
expect(id).toMatch(/^EC-\d{8}-\d{6}$/);
|
||||
});
|
||||
|
||||
it("usa la fecha proporcionada correctamente", () => {
|
||||
const date = new Date("2026-05-06T00:00:00.000Z");
|
||||
const id = generateTrackingId(date);
|
||||
// El año/mes/día depende de la zona horaria local del proceso
|
||||
// Solo validamos el patrón
|
||||
expect(id).toMatch(/^EC-\d{8}-\d{6}$/);
|
||||
});
|
||||
|
||||
it("incluye el año 2026 si se pasa esa fecha", () => {
|
||||
const date = new Date(2026, 4, 6); // May 6, 2026 — local
|
||||
const id = generateTrackingId(date);
|
||||
expect(id.startsWith("EC-2026")).toBe(true);
|
||||
});
|
||||
|
||||
it("genera IDs distintos en llamadas consecutivas (probabilístico)", () => {
|
||||
const ids = new Set(Array.from({ length: 20 }, () => generateTrackingId()));
|
||||
// Con 1 millón de combinaciones, 20 llamadas deben ser todas distintas
|
||||
expect(ids.size).toBe(20);
|
||||
});
|
||||
|
||||
it("el prefijo es siempre EC-", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(generateTrackingId().startsWith("EC-")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("el sufijo siempre tiene exactamente 6 dígitos", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const parts = generateTrackingId().split("-");
|
||||
expect(parts[2]).toHaveLength(6);
|
||||
expect(/^\d{6}$/.test(parts[2])).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidTrackingId", () => {
|
||||
it("retorna true para IDs válidos", () => {
|
||||
expect(isValidTrackingId("EC-20260506-082341")).toBe(true);
|
||||
expect(isValidTrackingId("EC-20261231-000001")).toBe(true);
|
||||
expect(isValidTrackingId("EC-20260101-999999")).toBe(true);
|
||||
});
|
||||
|
||||
it("retorna false para IDs inválidos", () => {
|
||||
expect(isValidTrackingId("EC-2026050-082341")).toBe(false); // fecha corta
|
||||
expect(isValidTrackingId("EC-20260506-08234")).toBe(false); // sufijo corto
|
||||
expect(isValidTrackingId("US-20260506-082341")).toBe(false); // prefijo incorrecto
|
||||
expect(isValidTrackingId("EC-20260506082341")).toBe(false); // sin separador
|
||||
expect(isValidTrackingId("EC-20260506-08234A")).toBe(false); // letras en sufijo
|
||||
expect(isValidTrackingId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("los IDs generados siempre pasan la validación", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(isValidTrackingId(generateTrackingId())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Genera el Tracking ID único de un paquete.
|
||||
* Formato documentado: EC-YYYYMMDD-XXXXXX (doc §08)
|
||||
* Ejemplo: EC-20260506-082341
|
||||
*/
|
||||
export function generateTrackingId(date?: Date): string {
|
||||
const d = date ?? new Date();
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const random = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
|
||||
return `EC-${year}${month}${day}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que un Tracking ID tenga el formato correcto.
|
||||
*/
|
||||
export function isValidTrackingId(id: string): boolean {
|
||||
return /^EC-\d{8}-\d{6}$/.test(id);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
describe("HealthController", () => {
|
||||
let controller: HealthController;
|
||||
let prismaService: { client: { $queryRaw: jest.Mock } };
|
||||
|
||||
beforeEach(async () => {
|
||||
prismaService = {
|
||||
client: {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: prismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<HealthController>(HealthController);
|
||||
});
|
||||
|
||||
it("debe estar definido", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it("retorna status 'ok' cuando la DB responde", async () => {
|
||||
const result = await controller.health();
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.checks.database).toBe("ok");
|
||||
});
|
||||
|
||||
it("retorna status 'degraded' cuando la DB falla", async () => {
|
||||
prismaService.client.$queryRaw.mockRejectedValueOnce(new Error("DB down"));
|
||||
const result = await controller.health();
|
||||
expect(result.status).toBe("degraded");
|
||||
expect(result.checks.database).toBe("error");
|
||||
});
|
||||
|
||||
it("el response incluye service, version y timestamp", async () => {
|
||||
const result = await controller.health();
|
||||
expect(result.service).toBe("moraworld-api");
|
||||
expect(result.version).toBe("0.1.0");
|
||||
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("timestamp es una fecha ISO válida", async () => {
|
||||
const result = await controller.health();
|
||||
const date = new Date(result.timestamp);
|
||||
expect(date.getTime()).not.toBeNaN();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TrackingController } from "./tracking.controller";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
|
||||
describe("TrackingController (doc §07 — búsqueda pública sin login)", () => {
|
||||
let controller: TrackingController;
|
||||
let prismaService: { client: { package: { findUnique: jest.Mock } } };
|
||||
|
||||
const mockPackage = {
|
||||
trackingId: "EC-20260506-082341",
|
||||
status: "REGISTRADO",
|
||||
description: "Tenis Nike Air Max",
|
||||
store: "Amazon",
|
||||
vendorTracking: "1Z999AA10123456784",
|
||||
senaeCategory: "REGIMEN_4X4",
|
||||
createdAt: new Date("2026-05-06T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-06T10:00:00.000Z"),
|
||||
statusHistory: [
|
||||
{
|
||||
status: "REGISTRADO",
|
||||
note: "Paquete registrado por el cliente",
|
||||
createdAt: new Date("2026-05-06T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
prismaService = {
|
||||
client: {
|
||||
package: {
|
||||
findUnique: jest.fn().mockResolvedValue(mockPackage),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TrackingController],
|
||||
providers: [
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: prismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<TrackingController>(TrackingController);
|
||||
});
|
||||
|
||||
it("debe estar definido", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it("retorna el paquete cuando el Tracking ID es válido y existe", async () => {
|
||||
const result = await controller.findByTrackingId("EC-20260506-082341");
|
||||
expect(result.trackingId).toBe("EC-20260506-082341");
|
||||
expect(result.status).toBe("REGISTRADO");
|
||||
expect(result.description).toBe("Tenis Nike Air Max");
|
||||
expect(result.timeline).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("incluye el timeline (historial de estados)", async () => {
|
||||
const result = await controller.findByTrackingId("EC-20260506-082341");
|
||||
expect(Array.isArray(result.timeline)).toBe(true);
|
||||
expect(result.timeline[0].status).toBe("REGISTRADO");
|
||||
});
|
||||
|
||||
it("lanza NotFoundException si el Tracking ID no existe en DB", async () => {
|
||||
prismaService.client.package.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
controller.findByTrackingId("EC-20260506-999999"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("lanza NotFoundException para formato de ID inválido (evita consultas innecesarias)", async () => {
|
||||
await expect(
|
||||
controller.findByTrackingId("INVALIDO"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
// No debe llegar a consultar la DB
|
||||
expect(prismaService.client.package.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lanza NotFoundException para ID con prefijo incorrecto", async () => {
|
||||
await expect(
|
||||
controller.findByTrackingId("US-20260506-082341"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("lanza NotFoundException para ID con fecha malformada", async () => {
|
||||
await expect(
|
||||
controller.findByTrackingId("EC-2026050-082341"),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("retorna null para senaeCategory si no está definida", async () => {
|
||||
prismaService.client.package.findUnique.mockResolvedValueOnce({
|
||||
...mockPackage,
|
||||
senaeCategory: null,
|
||||
});
|
||||
const result = await controller.findByTrackingId("EC-20260506-082341");
|
||||
expect(result.senaeCategory).toBeNull();
|
||||
});
|
||||
|
||||
it("no expone datos sensibles (sin userId ni tenantId)", async () => {
|
||||
const result = await controller.findByTrackingId("EC-20260506-082341") as Record<string, unknown>;
|
||||
expect(result["userId"]).toBeUndefined();
|
||||
expect(result["tenantId"]).toBeUndefined();
|
||||
expect(result["declaredValue"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Param, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { isValidTrackingId } from "../common/utils/tracking-id.util";
|
||||
|
||||
@Controller("tracking")
|
||||
export class TrackingController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* GET /api/tracking/:id
|
||||
* Búsqueda pública por Tracking ID — sin necesidad de login (doc §07 Módulo de Tracking).
|
||||
* Retorna el estado y el historial del paquete.
|
||||
*/
|
||||
@Get(":id")
|
||||
async findByTrackingId(@Param("id") id: string) {
|
||||
if (!isValidTrackingId(id)) {
|
||||
throw new NotFoundException(
|
||||
`Formato de Tracking ID inválido. Esperado: EC-YYYYMMDD-XXXXXX. Recibido: ${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = await this.prisma.client.package.findUnique({
|
||||
where: { trackingId: id },
|
||||
select: {
|
||||
trackingId: true,
|
||||
status: true,
|
||||
description: true,
|
||||
store: true,
|
||||
vendorTracking: true,
|
||||
senaeCategory: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
statusHistory: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
status: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new NotFoundException(`No se encontró un paquete con el Tracking ID: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
trackingId: pkg.trackingId,
|
||||
status: pkg.status,
|
||||
description: pkg.description,
|
||||
store: pkg.store ?? null,
|
||||
vendorTracking: pkg.vendorTracking ?? null,
|
||||
senaeCategory: pkg.senaeCategory ?? null,
|
||||
timeline: pkg.statusHistory,
|
||||
lastUpdate: pkg.updatedAt,
|
||||
registered: pkg.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TrackingController } from "./tracking.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [TrackingController],
|
||||
})
|
||||
export class TrackingModule {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Calculadora de Envíos SENAE — Moraworld Imports",
|
||||
description: "Calcula el costo total de tu envío desde EE.UU. a Ecuador incluyendo FODINFA, IVA, arancel y flete. Sin registro.",
|
||||
};
|
||||
|
||||
export default function CalculadoraPage() {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
|
||||
<div style={{ maxWidth: 700, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
|
||||
Calculadora SENAE
|
||||
</h1>
|
||||
<p style={{ color: "#6B7280" }}>
|
||||
Sin necesidad de registro. Conoce el costo total antes de comprar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nota: el formulario interactivo se implementa en Fase 1 con client component */}
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: 32,
|
||||
border: "1px solid #E5E7EB", marginBottom: 24,
|
||||
}}>
|
||||
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
|
||||
Ingresa los datos de tu paquete
|
||||
</h2>
|
||||
<p style={{ color: "#6B7280", fontSize: ".88rem", marginBottom: 20 }}>
|
||||
El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el
|
||||
endpoint de la API directamente:
|
||||
</p>
|
||||
<div style={{
|
||||
background: "#0D1117", borderRadius: 10, padding: "16px 20px",
|
||||
fontFamily: "monospace", fontSize: ".8rem", color: "#A5B4FC",
|
||||
lineHeight: 2,
|
||||
}}>
|
||||
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: 8 }}>{`// GET /api/calculator`}</div>
|
||||
<div>
|
||||
<span style={{ color: "#6EE7B7" }}>GET</span>{" "}
|
||||
{apiUrl}/api/calculator?<span style={{ color: "#FCD34D" }}>value</span>=150&
|
||||
<span style={{ color: "#FCD34D" }}>weight</span>=2&
|
||||
<span style={{ color: "#FCD34D" }}>category</span>=REGIMEN_4X4
|
||||
</div>
|
||||
<div style={{ marginTop: 12, color: "rgba(255,255,255,.3)" }}>
|
||||
{`// Parámetros: value, weight, category, length, width, height, shipments`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categorías SENAE informativas */}
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: 32,
|
||||
border: "1px solid #E5E7EB",
|
||||
}}>
|
||||
<h2 style={{ fontWeight: 700, marginBottom: 20, fontSize: "1rem" }}>
|
||||
Categorías SENAE aplicables
|
||||
</h2>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
{[
|
||||
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta $400 · 4 kg · 4 envíos/año" },
|
||||
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes generales" },
|
||||
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
|
||||
{ name: "Categoría D", rate: "0–15%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
|
||||
].map((c) => (
|
||||
<div key={c.name} style={{
|
||||
background: c.bg, borderRadius: 10, padding: 16, textAlign: "center",
|
||||
}}>
|
||||
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
|
||||
<div style={{ fontSize: "1.8rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 6 }}>{c.rate}</div>
|
||||
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 20, padding: 16, background: "#F3F4F6", borderRadius: 8 }}>
|
||||
<p style={{ fontSize: ".82rem", color: "#6B7280", lineHeight: 1.6 }}>
|
||||
<strong>Fórmula:</strong> Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+438
-103
@@ -1,115 +1,450 @@
|
||||
async function getApiHealth() {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/health`, {
|
||||
next: { revalidate: 10 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function HomePage() {
|
||||
const health = await getApiHealth();
|
||||
export const metadata: Metadata = {
|
||||
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
|
||||
description:
|
||||
"Compra en Amazon, eBay, Walmart y más. Recibe en tu Suite en New Jersey y enviamos a Ecuador con gestión aduanera SENAE incluida.",
|
||||
keywords: ["casillero New Jersey", "importaciones Ecuador", "SENAE", "Amazon Ecuador", "envíos EE.UU. Ecuador"],
|
||||
};
|
||||
|
||||
// ─── Datos estáticos de la landing (sin DB, SSG) ──────────────
|
||||
|
||||
const TRACKING_STATES = [
|
||||
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "El cliente registró la compra" },
|
||||
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "El vendedor despachó hacia bodega NJ" },
|
||||
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
|
||||
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando, midiendo y fotografiando" },
|
||||
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado. Cobro aplicado." },
|
||||
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por la SENAE" },
|
||||
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo hacia Ecuador" },
|
||||
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana Ecuador" },
|
||||
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega al cliente" },
|
||||
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Paquete entregado en Ecuador ✓" },
|
||||
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
|
||||
];
|
||||
|
||||
const SENAE_CATEGORIES = [
|
||||
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta 4 envíos/año · máx. $400 · máx. 4 kg · Sin arancel" },
|
||||
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes de consumo general" },
|
||||
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
|
||||
{ name: "Categoría D", rate: "0–15%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
|
||||
];
|
||||
|
||||
const MODULES = [
|
||||
{ icon: "🌐", title: "Landing Pública", desc: "Calculadora SENAE, tracking sin login, tarifas y FAQ", tag: "Sin registro" },
|
||||
{ icon: "👤", title: "Portal Cliente", desc: "Suite personalizada, pre-alertas, mis paquetes, historial", tag: "B2C · Ecuador" },
|
||||
{ icon: "🏭", title: "Portal Bodega NJ", desc: "Recepción, peso, fotos, despacho desde 150 N Day St", tag: "Operador · NJ" },
|
||||
{ icon: "⚙️", title: "Portal Admin", desc: "Dashboard, usuarios, tarifas, reportes, auditoría", tag: "Admin / Super Admin" },
|
||||
{ icon: "🛃", title: "Módulo SENAE", desc: "DSI automática, cálculo FODINFA+IVA+Arancel, WebService", tag: "Agente Aduanero" },
|
||||
{ icon: "📍", title: "Tracking", desc: "ID único EC-YYYYMMDD-XXXXXX · 11 estados · búsqueda pública", tag: "Todos los roles" },
|
||||
{ icon: "📄", title: "Pre-alerta", desc: "Aviso previo con factura antes de llegada a bodega", tag: "Cliente" },
|
||||
{ icon: "🚢", title: "Carga Pesada B2B", desc: "Pallets, contenedores, cotización especial, INEN", tag: "Importadores" },
|
||||
];
|
||||
|
||||
const ROLES = [
|
||||
{ icon: "👑", name: "Super Admin", desc: "Acceso total. Gestiona tenants y config del sistema.", badge: "Acceso total", bg: "#EEF3FF" },
|
||||
{ icon: "🏢", name: "Admin Empresa", desc: "Administra su tenant: usuarios, tarifas, reportes.", badge: "Tenant propio", bg: "#EDE9FE" },
|
||||
{ icon: "📦", name: "Operador Bodega", desc: "Personal en NJ: verifica, pesa, fotografía y despacha.", badge: "Bodega NJ", bg: "#FEF3C7" },
|
||||
{ icon: "🛃", name: "Agente Aduanero", desc: "Revisa y aprueba declaraciones ante la SENAE.", badge: "SENAE", bg: "#D1FAE5" },
|
||||
{ icon: "🧑💻", name: "Cliente Final", desc: "Registra compras, pre-alertas y hace seguimiento.", badge: "B2C · Ecuador", bg: "#FFF3E8" },
|
||||
{ icon: "🎧", name: "Soporte", desc: "Solo lectura: puede ver info de clientes y paquetes.", badge: "Solo lectura", bg: "#F3F4F6" },
|
||||
];
|
||||
|
||||
// ─── Componentes ──────────────────────────────────────────────
|
||||
|
||||
function Navbar() {
|
||||
return (
|
||||
<main>
|
||||
<header
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-200)",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 800, fontSize: "1.2rem", color: "var(--primary)" }}>
|
||||
Moraworld<span style={{ color: "var(--accent)" }}>.</span>Imports
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: ".75rem",
|
||||
fontWeight: 600,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: health?.status === "ok" ? "#D1FAE5" : "#FEF3C7",
|
||||
color: health?.status === "ok" ? "#065F46" : "#92400E",
|
||||
}}
|
||||
>
|
||||
API: {health?.status === "ok" ? "conectada" : "local / pendiente"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
|
||||
padding: "80px 24px",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<p
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background: "rgba(0,87,255,.15)",
|
||||
border: "1px solid rgba(0,87,255,.3)",
|
||||
color: "#7AADFF",
|
||||
fontSize: ".8rem",
|
||||
fontWeight: 600,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 999,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Fase 0 — Fundación del software
|
||||
</p>
|
||||
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 16 }}>
|
||||
Tu casillero en <em style={{ color: "var(--accent)", fontStyle: "normal" }}>New Jersey</em>
|
||||
<br />
|
||||
para recibir en Ecuador
|
||||
</h1>
|
||||
<p style={{ color: "#9CA3AF", maxWidth: 520, marginBottom: 32 }}>
|
||||
Monorepo activo: Next.js + NestJS + PostgreSQL. Desarrollo local con Docker; despliegue en Coolify.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<a
|
||||
href="/status"
|
||||
style={{
|
||||
background: "var(--primary)",
|
||||
color: "#fff",
|
||||
padding: "14px 28px",
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Ver estado del sistema →
|
||||
</a>
|
||||
<span
|
||||
style={{
|
||||
border: "2px solid rgba(255,255,255,.2)",
|
||||
color: "#9CA3AF",
|
||||
padding: "12px 26px",
|
||||
borderRadius: 8,
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
Prototipos HTML en la raíz del repo
|
||||
<nav style={{
|
||||
position: "sticky", top: 0, zIndex: 50,
|
||||
background: "rgba(255,255,255,0.95)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid #E5E7EB",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "0 40px", height: 60,
|
||||
}}>
|
||||
<span style={{ fontWeight: 800, fontSize: "1.15rem", color: "#111827" }}>
|
||||
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "center", fontSize: ".9rem" }}>
|
||||
<Link href="/calculadora" style={{ color: "#4B5563" }}>Calculadora</Link>
|
||||
<Link href="/tracking" style={{ color: "#4B5563" }}>Tracking</Link>
|
||||
<Link href="/carga-pesada" style={{ color: "#4B5563" }}>Carga Pesada</Link>
|
||||
<Link href="/portal" style={{
|
||||
background: "#0057FF", color: "#fff",
|
||||
padding: "8px 20px", borderRadius: 8, fontWeight: 600,
|
||||
}}>Mi Portal</Link>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero() {
|
||||
return (
|
||||
<section style={{
|
||||
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
|
||||
padding: "100px 40px", color: "#fff",
|
||||
}}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<div style={{
|
||||
display: "inline-block",
|
||||
background: "rgba(0,87,255,.15)", border: "1px solid rgba(0,87,255,.3)",
|
||||
color: "#7AADFF", fontSize: ".8rem", fontWeight: 600,
|
||||
padding: "6px 14px", borderRadius: 999, marginBottom: 24,
|
||||
}}>
|
||||
ISO/IEC 27001 · SENAE Autorizado · Cuenca, Ecuador
|
||||
</div>
|
||||
<h1 style={{ fontSize: "clamp(2rem,5vw,3.2rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 20 }}>
|
||||
Tu casillero en <span style={{ color: "#FF6B00" }}>New Jersey</span>
|
||||
<br />para recibir en Ecuador
|
||||
</h1>
|
||||
<p style={{ color: "#9CA3AF", maxWidth: 560, fontSize: "1.05rem", marginBottom: 16 }}>
|
||||
Compra en Amazon, eBay, Walmart y más. Te asignamos una Suite personal en nuestra
|
||||
bodega en NJ. Gestionamos el envío y la declaración aduanera SENAE por ti.
|
||||
</p>
|
||||
<div style={{
|
||||
background: "rgba(0,87,255,.1)", border: "1px solid rgba(0,87,255,.25)",
|
||||
borderRadius: 10, padding: "14px 18px", marginBottom: 32,
|
||||
fontFamily: "monospace", fontSize: ".9rem", color: "#A5B4FC", maxWidth: 520,
|
||||
}}>
|
||||
📍 <strong style={{ color: "#FF6B00" }}>Tu Suite:</strong>{" "}
|
||||
150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050, EE.UU.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<Link href="/registro" style={{
|
||||
background: "#0057FF", color: "#fff",
|
||||
padding: "14px 28px", borderRadius: 8, fontWeight: 700, fontSize: ".95rem",
|
||||
}}>
|
||||
Crear mi Suite gratis →
|
||||
</Link>
|
||||
<Link href="/calculadora" style={{
|
||||
border: "2px solid rgba(255,255,255,.2)", color: "#9CA3AF",
|
||||
padding: "12px 26px", borderRadius: 8, fontSize: ".9rem",
|
||||
}}>
|
||||
Calcular mi envío
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 32, marginTop: 48, flexWrap: "wrap" }}>
|
||||
{[
|
||||
["🏢", "Moraworld Imports S.A.S.", "Ecuador · RUC activo"],
|
||||
["🏭", "Mora Global Import LLC", "New Jersey, EE.UU."],
|
||||
["🛃", "Autorización SENAE", "Declaración automática"],
|
||||
].map(([icon, title, sub]) => (
|
||||
<div key={title} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: "1.5rem" }}>{icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: ".88rem", color: "#fff" }}>{title}</div>
|
||||
<div style={{ fontSize: ".78rem", color: "#6B7280" }}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
<section style={{ padding: "48px 24px", maxWidth: 1100, margin: "0 auto" }}>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, marginBottom: 16 }}>Próximos pasos (Fase 1)</h2>
|
||||
<ul style={{ color: "var(--gray-500)", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<li>Registro e inicio de sesión con JWT + MFA</li>
|
||||
<li>Asignación automática de Suite (EC-XXXXX)</li>
|
||||
<li>Pre-alertas y registro de paquetes</li>
|
||||
<li>Migración del portal desde portal-cliente.html</li>
|
||||
function ServicesSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 48 }}>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Dos servicios, un solo sistema</h2>
|
||||
<p style={{ color: "#6B7280", fontSize: ".95rem" }}>Casillero personal para compras online y carga pesada para importadores B2B.</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
|
||||
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #BFDBFE" }}>
|
||||
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>📦</div>
|
||||
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Casillero Personal</h3>
|
||||
<p style={{ color: "#6B7280", marginBottom: 16 }}>
|
||||
Para compras individuales en Amazon, eBay, Walmart y más. Suite personalizada, tracking en tiempo real
|
||||
y declaración SENAE automática bajo el régimen 4×4.
|
||||
</p>
|
||||
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
|
||||
{["Suite virtual asignada al registrarse", "Pre-alerta con carga de factura", "Calculadora de costos SENAE", "11 estados de tracking en tiempo real", "Notificaciones WhatsApp · SMS · Email"].map(i => (
|
||||
<li key={i}>{i}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/casillero" style={{
|
||||
display: "inline-block", marginTop: 20, background: "#0057FF", color: "#fff",
|
||||
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
|
||||
}}>
|
||||
Conocer más →
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #FED7AA" }}>
|
||||
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>🚢</div>
|
||||
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Carga Pesada / Pallets B2B</h3>
|
||||
<p style={{ color: "#6B7280", marginBottom: 16 }}>
|
||||
Para importadores mayoristas: pallets, contenedores, volumen alto. Cotización personalizada,
|
||||
certificación INEN y declaración formal DAI ante la SENAE.
|
||||
</p>
|
||||
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
|
||||
{["Cotización especial por tipo de mercancía", "Verificación de requisitos INEN", "Declaración formal DAI", "Asesoría en partidas arancelarias", "Expediente B2B con ID de seguimiento"].map(i => (
|
||||
<li key={i}>{i}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/carga-pesada/cotizacion" style={{
|
||||
display: "inline-block", marginTop: 20, background: "#FF6B00", color: "#fff",
|
||||
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
|
||||
}}>
|
||||
Solicitar cotización →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ModulesSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#fff" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 48 }}>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>8 módulos integrados</h2>
|
||||
<p style={{ color: "#6B7280" }}>Toda la operación logística cubierta en una sola plataforma.</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16 }}>
|
||||
{MODULES.map((m) => (
|
||||
<div key={m.title} style={{
|
||||
background: "#F9FAFB", borderRadius: 12, padding: 20,
|
||||
border: "1px solid #E5E7EB", transition: "box-shadow .2s",
|
||||
}}>
|
||||
<div style={{ fontSize: "1.8rem", marginBottom: 10 }}>{m.icon}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: ".9rem", marginBottom: 6 }}>{m.title}</div>
|
||||
<p style={{ color: "#6B7280", fontSize: ".8rem", marginBottom: 10, lineHeight: 1.5 }}>{m.desc}</p>
|
||||
<span style={{
|
||||
background: "#EEF3FF", color: "#0057FF",
|
||||
fontSize: ".68rem", fontWeight: 700, padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
}}>{m.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 48, alignItems: "start" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 12 }}>
|
||||
Tracking en tiempo real
|
||||
</h2>
|
||||
<p style={{ color: "#6B7280", marginBottom: 16 }}>
|
||||
Cada paquete recibe un ID único con 11 estados que cubren todo el ciclo de vida.
|
||||
Búsqueda pública sin necesidad de crear cuenta.
|
||||
</p>
|
||||
<div style={{
|
||||
background: "#0D1117", borderRadius: 10, padding: "14px 18px",
|
||||
fontFamily: "monospace", fontSize: ".88rem", color: "#A5B4FC",
|
||||
marginBottom: 20, border: "1px solid rgba(0,87,255,.3)",
|
||||
}}>
|
||||
Formato: <strong style={{ color: "#FF6B00" }}>EC-YYYYMMDD-XXXXXX</strong><br />
|
||||
Ejemplo: <strong>EC-20260506-082341</strong>
|
||||
</div>
|
||||
<Link href="/tracking" style={{
|
||||
background: "#0057FF", color: "#fff",
|
||||
padding: "12px 24px", borderRadius: 8, fontWeight: 600, fontSize: ".9rem",
|
||||
display: "inline-block",
|
||||
}}>
|
||||
Buscar mi paquete →
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{TRACKING_STATES.map((s) => (
|
||||
<div key={s.num} style={{
|
||||
display: "flex", gap: 12, alignItems: "center",
|
||||
background: "#fff", borderRadius: 8, padding: "8px 14px",
|
||||
border: "1px solid #E5E7EB",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: "50%",
|
||||
background: s.color, flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontFamily: "monospace", fontSize: ".68rem", color: "#9CA3AF", minWidth: 22 }}>{s.num}</span>
|
||||
<span style={{ fontFamily: "monospace", fontSize: ".78rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
|
||||
<span style={{ fontSize: ".75rem", color: "#6B7280" }}>{s.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CalculatorPreviewSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#fff" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
|
||||
Calculadora SENAE gratuita
|
||||
</h2>
|
||||
<p style={{ color: "#6B7280", marginBottom: 40 }}>
|
||||
Sin registro. Conoce el costo total de tu envío antes de comprar.
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 16, marginBottom: 32 }}>
|
||||
{SENAE_CATEGORIES.map((c) => (
|
||||
<div key={c.name} style={{
|
||||
borderRadius: 12, padding: 24, background: c.bg,
|
||||
border: `1px solid ${c.color}30`, textAlign: "center",
|
||||
}}>
|
||||
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
|
||||
<div style={{ fontSize: "2.2rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 8 }}>{c.rate}</div>
|
||||
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
background: "#0D1117", borderRadius: 12, padding: "20px 24px",
|
||||
fontFamily: "monospace", fontSize: ".82rem", color: "#A5B4FC",
|
||||
textAlign: "left", maxWidth: 600, margin: "0 auto 28px",
|
||||
lineHeight: 2, border: "1px solid rgba(255,255,255,.08)",
|
||||
}}>
|
||||
<span style={{ color: "rgba(255,255,255,.25)" }}>// Fórmula (doc §15)</span><br />
|
||||
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Flete</span> = Peso_final × <span style={{ color: "#FCD34D" }}>$3.50</span>/lb<br />
|
||||
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Seguro</span> = Valor × <span style={{ color: "#FCD34D" }}>2%</span><br />
|
||||
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>FODINFA</span> = Valor × <span style={{ color: "#FCD34D" }}>0.5%</span><br />
|
||||
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Arancel</span> = Valor × <span style={{ color: "#FCD34D" }}>% categoría</span><br />
|
||||
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>IVA</span> = (Valor + FODINFA + Arancel) × <span style={{ color: "#FCD34D" }}>15%</span><br />
|
||||
<span style={{ color: "#F9A8D4", fontWeight: 700 }}>TOTAL</span> = Flete + Seguro + FODINFA + Arancel + IVA
|
||||
</div>
|
||||
<Link href="/calculadora" style={{
|
||||
background: "#0057FF", color: "#fff",
|
||||
padding: "14px 32px", borderRadius: 8, fontWeight: 700,
|
||||
display: "inline-block", fontSize: ".95rem",
|
||||
}}>
|
||||
Calcular mi envío →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: 48 }}>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>6 roles, accesos diferenciados</h2>
|
||||
<p style={{ color: "#6B7280" }}>Cada usuario tiene exactamente lo que necesita. Ni más, ni menos.</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 16 }}>
|
||||
{ROLES.map((r) => (
|
||||
<div key={r.name} style={{
|
||||
background: r.bg, borderRadius: 12, padding: 20,
|
||||
border: "1px solid #E5E7EB",
|
||||
}}>
|
||||
<div style={{ fontSize: "1.5rem", marginBottom: 10 }}>{r.icon}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6 }}>{r.name}</div>
|
||||
<p style={{ color: "#6B7280", fontSize: ".82rem", marginBottom: 12, lineHeight: 1.5 }}>{r.desc}</p>
|
||||
<span style={{
|
||||
background: "rgba(0,0,0,.06)", fontSize: ".7rem", fontWeight: 700,
|
||||
padding: "2px 10px", borderRadius: 999,
|
||||
}}>{r.badge}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompaniesSection() {
|
||||
return (
|
||||
<section style={{ padding: "80px 40px", background: "#fff" }}>
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Operación legal en dos países</h2>
|
||||
<p style={{ color: "#6B7280", marginBottom: 40 }}>Empresas constituidas legalmente en Ecuador y EE.UU.</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, maxWidth: 800, margin: "0 auto" }}>
|
||||
{[
|
||||
{
|
||||
flag: "🇪🇨",
|
||||
name: "Moraworld Imports S.A.S.",
|
||||
detail: "Constituida en Ecuador. Representante legal en Cuenca. RUC activo, permisos aduaneros vigentes ante la SENAE.",
|
||||
badge: "Ecuador · S.A.S. · SENAE · Cuenca",
|
||||
},
|
||||
{
|
||||
flag: "🇺🇸",
|
||||
name: "Mora Global Import LLC",
|
||||
detail: "LLC registrada en New Jersey, EE.UU. Bodega operativa en 150 N Day St, City of Orange, NJ 07050.",
|
||||
badge: "New Jersey · LLC · NJ 07050",
|
||||
},
|
||||
].map((c) => (
|
||||
<div key={c.name} style={{
|
||||
background: "#fff", borderRadius: 16, padding: 28,
|
||||
border: "2px solid #E5E7EB", display: "flex", gap: 16, alignItems: "flex-start",
|
||||
}}>
|
||||
<span style={{ fontSize: "2.5rem" }}>{c.flag}</span>
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<div style={{ fontWeight: 800, marginBottom: 6 }}>{c.name}</div>
|
||||
<p style={{ color: "#6B7280", fontSize: ".82rem", lineHeight: 1.6, marginBottom: 10 }}>{c.detail}</p>
|
||||
<span style={{
|
||||
background: "#EEF3FF", color: "#0057FF",
|
||||
fontSize: ".7rem", fontWeight: 700, padding: "2px 10px", borderRadius: 999,
|
||||
}}>{c.badge}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer style={{
|
||||
background: "#0D1117", padding: "48px 40px", color: "rgba(255,255,255,.5)",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<div style={{ fontSize: "1.1rem", fontWeight: 800, color: "#fff", marginBottom: 8 }}>
|
||||
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
|
||||
</div>
|
||||
<p style={{ fontSize: ".82rem", marginBottom: 20 }}>
|
||||
Moraworld Imports S.A.S. · Mora Global Import LLC
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center", marginBottom: 24 }}>
|
||||
{["ISO/IEC 27001", "SENAE Autorizado", "INEN", "LOPDP Ecuador", "Multi-Tenant"].map((b) => (
|
||||
<span key={b} style={{
|
||||
background: "rgba(255,255,255,.07)", color: "rgba(255,255,255,.5)",
|
||||
fontSize: ".7rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999,
|
||||
}}>{b}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 24, justifyContent: "center", flexWrap: "wrap", fontSize: ".82rem" }}>
|
||||
<Link href="/como-funciona">Cómo funciona</Link>
|
||||
<Link href="/calculadora">Calculadora</Link>
|
||||
<Link href="/tracking">Tracking</Link>
|
||||
<Link href="/tarifas">Tarifas</Link>
|
||||
<Link href="/quienes-somos">Quiénes somos</Link>
|
||||
<Link href="/casillero">Casillero</Link>
|
||||
<Link href="/carga-pesada">Carga Pesada</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Página principal ─────────────────────────────────────────
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Hero />
|
||||
<ServicesSection />
|
||||
<ModulesSection />
|
||||
<TrackingSection />
|
||||
<CalculatorPreviewSection />
|
||||
<RolesSection />
|
||||
<CompaniesSection />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Rastrear Paquete — Moraworld Imports",
|
||||
description: "Busca tu paquete con el Tracking ID (formato EC-YYYYMMDD-XXXXXX). Sin necesidad de iniciar sesión.",
|
||||
};
|
||||
|
||||
export default function TrackingPage() {
|
||||
return (
|
||||
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
|
||||
<div style={{ maxWidth: 600, margin: "0 auto" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Link href="/" style={{ color: "#6B7280", fontSize: ".88rem" }}>← Inicio</Link>
|
||||
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginTop: 8, marginBottom: 8 }}>
|
||||
Rastrear paquete
|
||||
</h1>
|
||||
<p style={{ color: "#6B7280" }}>
|
||||
Ingresa tu Tracking ID para ver el estado de tu paquete. No necesitas iniciar sesión.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: 32,
|
||||
border: "1px solid #E5E7EB", marginBottom: 24,
|
||||
}}>
|
||||
{/* Formulario — cliente component en Fase 1 */}
|
||||
<label style={{ display: "block", fontWeight: 600, marginBottom: 8, fontSize: ".9rem" }}>
|
||||
Tracking ID
|
||||
</label>
|
||||
<div style={{
|
||||
background: "#F3F4F6", borderRadius: 8, padding: "12px 16px",
|
||||
fontFamily: "monospace", color: "#9CA3AF", fontSize: ".9rem",
|
||||
border: "1px solid #E5E7EB", marginBottom: 8,
|
||||
}}>
|
||||
EC-20260506-082341
|
||||
</div>
|
||||
<p style={{ color: "#9CA3AF", fontSize: ".78rem", marginBottom: 20 }}>
|
||||
Formato: <strong style={{ fontFamily: "monospace" }}>EC-YYYYMMDD-XXXXXX</strong>
|
||||
</p>
|
||||
<div style={{
|
||||
background: "#EEF3FF", borderRadius: 8, padding: "12px 16px",
|
||||
fontSize: ".84rem", color: "#1D4ED8",
|
||||
}}>
|
||||
El formulario de búsqueda interactiva se habilita en Fase 1.
|
||||
Por ahora puedes usar la API: <code style={{ fontFamily: "monospace" }}>GET /api/tracking/:id</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: 32,
|
||||
border: "1px solid #E5E7EB",
|
||||
}}>
|
||||
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
|
||||
11 estados del ciclo de vida
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{[
|
||||
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "Cliente registró la compra" },
|
||||
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "Despachado hacia NJ" },
|
||||
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
|
||||
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando y fotografiando" },
|
||||
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado" },
|
||||
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por SENAE" },
|
||||
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo a Ecuador" },
|
||||
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana" },
|
||||
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega" },
|
||||
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Entregado al cliente ✓" },
|
||||
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
|
||||
].map((s) => (
|
||||
<div key={s.num} style={{
|
||||
display: "flex", gap: 10, alignItems: "center",
|
||||
padding: "8px 12px", background: "#F9FAFB",
|
||||
borderRadius: 6, border: "1px solid #E5E7EB",
|
||||
}}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: "50%", background: s.color, flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: "monospace", fontSize: ".65rem", color: "#9CA3AF", minWidth: 18 }}>{s.num}</span>
|
||||
<span style={{ fontFamily: "monospace", fontSize: ".75rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
|
||||
<span style={{ fontSize: ".72rem", color: "#6B7280" }}>{s.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,14 @@
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"test:cov": "turbo run test:cov",
|
||||
"test:ci": "turbo run test:ci",
|
||||
"setup": "npm install -g pnpm@9.15.0 && pnpm install",
|
||||
"db:generate": "pnpm --filter @moraworld/database generate",
|
||||
"db:migrate": "pnpm --filter @moraworld/database migrate:dev",
|
||||
"db:push": "pnpm --filter @moraworld/database push",
|
||||
"db:seed": "pnpm --filter @moraworld/database seed",
|
||||
"db:studio": "pnpm --filter @moraworld/database studio",
|
||||
"docker:up": "docker compose up -d",
|
||||
"docker:down": "docker compose down",
|
||||
|
||||
@@ -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 {
|
||||
@@ -55,6 +88,8 @@ model Tenant {
|
||||
suites Suite[]
|
||||
packages Package[]
|
||||
preAlerts PreAlert[]
|
||||
tariffs Tariff[]
|
||||
b2bRequests B2BRequest[]
|
||||
}
|
||||
|
||||
// ─── Usuarios ────────────────────────────────────────────────
|
||||
@@ -78,13 +113,30 @@ model User {
|
||||
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);
|
||||
// ─── 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)");
|
||||
|
||||
const suiteCode = "EC-00001";
|
||||
const demoEmail = "demo@moraworld.test";
|
||||
// ─── 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: demoEmail } },
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: u.email } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
console.log("ℹ Usuario demo se creará en Fase 1 (auth con bcrypt)");
|
||||
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()
|
||||
|
||||
Generated
+2177
File diff suppressed because it is too large
Load Diff
+12
@@ -11,6 +11,18 @@
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:cov": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user