feat: Fase 2 — SENAE DSI, verificación bodega, fotos, notificaciones, auditoría, tarifas CRUD, páginas faltantes

API:
- PackagesService: verifyPackage (discrepancia >10%), addPhotos (multer disk), generateSenaeDeclaration (DSI stub)
- PATCH /packages/:id/verify — peso real + dimensiones + detección automática de discrepancia
- POST  /packages/:id/photos — upload multipart fotos (hasta 10, 10 MB c/u)
- POST  /packages/:id/senae/declare — genera DSI, N° autorización SENAE, cambia estado DECLARACION_ADUANERA
- GET   /packages/pending-declaration — cola de paquetes VERIFICADO para agente aduanero
- AuditLogModule: GET /audit-logs con filtros (acción, recurso, fechas, paginación)
- NotificationsModule: notifyStatusChange → crea registros EMAIL/WHATSAPP/PUSH en DB (stub dispatch)
- TariffsModule: GET/PUT /tariffs — configuración de tarifas por tenant (pricePerLb, IVA, FODINFA…)
- ProductsModule: POST /products/scan — stub extracción de producto desde URL Amazon/eBay/Walmart
- StorageModule: saveFile/deleteFile con disco local (S3-ready)
- main.ts: NestExpressApplication + useStaticAssets('/uploads')
- Dirección bodega: de hardcoded a env vars (WAREHOUSE_ADDRESS_*)

Web:
- /bodega/verificacion — formulario real: peso real, dims, fotos, detección discrepancia, notificación
- /bodega/despacho — lista paquetes DECLARACION_ADUANERA → despacha a EN_TRANSITO_ECUADOR
- /bodega/declaraciones — cola SENAE: auto-detecta categoría 4×4, genera DSI por paquete
- /bodega/layout — agrega enlace Declaraciones SENAE, corrige ALLOWED roles
- /admin/tarifas — CRUD real: edita pricePerLb, IVA, FODINFA, límites 4×4; preview fórmulas
- /admin/auditoria — tabla paginada con filtros desde GET /audit-logs
- /portal/pre-alerta — tabs URL-scan / Manual, upload de factura, campos correctos
- /casillero/como-usar — timeline 9 pasos con dirección NJ y CTA
- /carga-pesada/como-funciona — flujo B2B 5 pasos, servicios incluidos
- /carga-pesada/inen — 6 categorías reguladas, callout de advertencia, proceso de asistencia
- api.ts: verify, uploadPhotos, senaeDeclare, pendingDeclaration, tariffs, auditLogs, products.scan
This commit is contained in:
Lizandro Guarnizo
2026-06-01 10:27:52 -05:00
parent 4872053fd1
commit 6e90f06f6d
33 changed files with 1712 additions and 257 deletions
+31 -43
View File
@@ -1,50 +1,38 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
import { IsString, IsOptional, IsNumber, IsPositive, IsEnum, Min } from "class-validator";
import { Type } from "class-transformer";
export class CreatePackageDto {
@IsString()
vendorTracking!: string;
@IsString()
description!: string;
@IsString()
store!: string;
@IsOptional()
@IsNumber()
@Min(0)
declaredValue?: number;
@IsOptional()
@IsNumber()
@Min(0)
declaredWeightLb?: number;
@IsOptional()
@IsNumber()
@Min(0)
lengthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
widthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
heightCm?: number;
/** userId del cliente al que pertenece este paquete */
@IsString()
userId!: string;
@IsString() userId!: string;
@IsString() description!: string;
@IsOptional() @IsString() store?: string;
@IsOptional() @IsString() vendorTracking?: string;
@IsOptional() @IsString() productUrl?: string;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) declaredValue?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() declaredWeightLb?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
}
export class UpdateStatusDto {
@IsString()
status!: string;
@IsEnum([
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
"VERIFICADO","DECLARACION_ADUANERA","EN_TRANSITO_ECUADOR","EN_ADUANA_ECUADOR",
"LISTO_ENTREGA","ENTREGADO","INCIDENCIA",
]) status!: string;
@IsOptional() @IsString() note?: string;
}
@IsOptional()
@IsString()
note?: string;
export class VerifyPackageDto {
@Type(() => Number) @IsNumber() @IsPositive() actualWeightLb!: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number;
@IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number;
@IsOptional() @IsString() note?: string;
}
export class SenaeDeclarationDto {
@IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"])
category!: string;
@IsOptional() @IsString() agentNotes?: string;
}
+92 -5
View File
@@ -1,19 +1,45 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
import {
Controller, Get, Post, Patch, Body, Param, Query,
UseGuards, UseInterceptors, UploadedFiles,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname, join } from "path";
import { existsSync, mkdirSync } from "fs";
import { randomUUID } from "crypto";
import { PackagesService } from "./packages.service";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { ConfigService } from "@nestjs/config";
@Controller("packages")
@UseGuards(JwtAuthGuard, RolesGuard)
export class PackagesController {
constructor(private svc: PackagesService) {}
private readonly apiUrl: string;
constructor(
private svc: PackagesService,
private config: ConfigService,
) {
this.apiUrl = this.config.get("API_URL", "http://localhost:3001");
}
@Get()
findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise<any[]> {
findAll(
@CurrentUser() user: any,
@Query("status") status?: string,
@Query("search") search?: string,
): Promise<any[]> {
return this.svc.findAll(user, { status, search });
}
@Get("pending-declaration")
@Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
pendingDeclaration(@CurrentUser() user: any): Promise<any[]> {
return this.svc.findPendingDeclaration(user.tenantId);
}
@Get(":id")
findOne(@Param("id") id: string, @CurrentUser() user: any): Promise<any> {
return this.svc.findOne(id, user);
@@ -27,7 +53,68 @@ export class PackagesController {
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise<any> {
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateStatusDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.updateStatus(id, dto, user.id);
}
/** Bodega: verify weight/dims + detect discrepancy (doc §10 step 4) */
@Patch(":id/verify")
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
verify(
@Param("id") id: string,
@Body() dto: VerifyPackageDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.verifyPackage(id, dto, user.id);
}
/** Bodega: upload photos via multipart form (doc §10 step 4) */
@Post(":id/photos")
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
@UseInterceptors(
FilesInterceptor("photos", 10, {
storage: diskStorage({
destination: (req, file, cb) => {
const pkgId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const dir = join(process.cwd(), "uploads", "packages", pkgId);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname)}`);
},
}),
fileFilter: (req, file, cb) => {
const allowed = /jpg|jpeg|png|gif|webp/;
cb(null, allowed.test(extname(file.originalname).toLowerCase()));
},
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
}),
)
async uploadPhotos(
@Param("id") id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: any,
): Promise<any> {
const baseUrl = this.apiUrl;
const photoUrls = (files || []).map(
f => `${baseUrl}/uploads/packages/${id}/${f.filename}`,
);
return this.svc.addPhotos(id, photoUrls, user.id);
}
/** Aduanero: generate SENAE DSI declaration (doc §11) */
@Post(":id/senae/declare")
@Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
senaeDeclare(
@Param("id") id: string,
@Body() dto: SenaeDeclarationDto,
@CurrentUser() user: any,
): Promise<any> {
return this.svc.generateSenaeDeclaration(id, dto, user.id);
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
import { Module } from "@nestjs/common";
import { PackagesController } from "./packages.controller";
import { PackagesService } from "./packages.service";
import { PackagesController } from "./packages.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { ConfigModule } from "@nestjs/config";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
controllers: [PackagesController],
imports: [PrismaModule, ConfigModule, NotificationsModule],
providers: [PackagesService],
controllers: [PackagesController],
exports: [PackagesService],
})
export class PackagesModule {}
+134 -4
View File
@@ -1,11 +1,15 @@
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { NotificationsService } from "../notifications/notifications.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto";
@Injectable()
export class PackagesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private notifications: NotificationsService,
) {}
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
const where: any = { tenantId: user.tenantId };
@@ -58,6 +62,7 @@ export class PackagesService {
description: dto.description,
store: dto.store,
vendorTracking: dto.vendorTracking,
productUrl: dto.productUrl,
declaredValue: dto.declaredValue ?? 0,
declaredWeight: dto.declaredWeightLb ?? null,
lengthCm: dto.lengthCm ?? null,
@@ -72,7 +77,7 @@ export class PackagesService {
packageId: pkg.id,
status: "REGISTRADO",
createdBy: operatorId,
note: "Paquete registrado al recibirse en bodega NJ",
note: "Paquete registrado en el sistema",
},
});
@@ -97,6 +102,131 @@ export class PackagesService {
},
});
// Notify user on status change
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return updated;
}
/**
* Bodega verification: record actual weight, dims, detect discrepancy >10% (doc §10 step 4).
* Sets status to EN_VERIFICACION then VERIFICADO.
*/
async verifyPackage(id: string, dto: VerifyPackageDto, operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
// Detect discrepancy: >10% difference between declared and actual weight
let hasDiscrepancy = false;
if (pkg.declaredWeight && dto.actualWeightLb) {
const declared = Number(pkg.declaredWeight);
const actual = dto.actualWeightLb;
const diff = Math.abs(actual - declared) / declared;
hasDiscrepancy = diff > 0.10;
}
const updated = await this.prisma.client.package.update({
where: { id },
data: {
actualWeight: dto.actualWeightLb,
lengthCm: dto.lengthCm ?? pkg.lengthCm,
widthCm: dto.widthCm ?? pkg.widthCm,
heightCm: dto.heightCm ?? pkg.heightCm,
hasDiscrepancy,
status: "VERIFICADO",
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: "VERIFICADO",
createdBy: operatorId,
note: dto.note ?? (hasDiscrepancy
? `DISCREPANCIA: peso declarado ${pkg.declaredWeight}lb vs real ${dto.actualWeightLb}lb`
: `Verificado: peso real ${dto.actualWeightLb}lb`),
},
});
// Notify user on verification
this.notifications.notifyStatusChange({ ...updated, hasDiscrepancy }, { id: updated.userId }).catch(() => {});
return { ...updated, hasDiscrepancy };
}
/**
* Add photo URLs to a package (doc §10 step 4, §07 Portal Bodega).
*/
async addPhotos(id: string, photoUrls: string[], operatorId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
const updated = await this.prisma.client.package.update({
where: { id },
data: { photos: { push: photoUrls } },
});
return updated;
}
/**
* SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA.
*/
async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
if (pkg.status !== "VERIFICADO") {
throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración.");
}
// Stub: In prod this would call SENAE SOAP/REST WebService
// Generate a plausible authorization number
const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
const declarationId = `DSI-${pkg.trackingId}`;
const updated = await this.prisma.client.package.update({
where: { id },
data: {
status: "DECLARACION_ADUANERA",
senaeCategory: dto.category as any,
senaeAuthNumber: authNumber,
senaeDeclarationId: declarationId,
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: "DECLARACION_ADUANERA",
createdBy: agentId,
note: dto.agentNotes ?? `DSI generada. Auth: ${authNumber}. Categoría: ${dto.category}`,
},
});
const result = {
...updated,
declarationId,
authNumber,
message: "Declaración simplificada (DSI) enviada y aprobada por la SENAE (stub).",
};
// Notify user of customs clearance
this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {});
return result;
}
/**
* Queue for SENAE: packages with status VERIFICADO pending declaration.
*/
async findPendingDeclaration(tenantId: string): Promise<any[]> {
return this.prisma.client.package.findMany({
where: { tenantId, status: "VERIFICADO" },
include: {
user: { select: { firstName: true, lastName: true, email: true } },
statusHistory: { orderBy: { createdAt: "desc" }, take: 1 },
},
orderBy: { updatedAt: "asc" },
});
}
}