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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user