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:
@@ -9,6 +9,11 @@ import { UsersModule } from "./users/users.module";
|
||||
import { PackagesModule } from "./packages/packages.module";
|
||||
import { PreAlertsModule } from "./pre-alerts/pre-alerts.module";
|
||||
import { B2BModule } from "./b2b/b2b.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { AuditLogModule } from "./audit-log/audit-log.module";
|
||||
import { NotificationsModule } from "./notifications/notifications.module";
|
||||
import { TariffsModule } from "./tariffs/tariffs.module";
|
||||
import { ProductsModule } from "./products/products.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +30,11 @@ import { B2BModule } from "./b2b/b2b.module";
|
||||
PackagesModule,
|
||||
PreAlertsModule,
|
||||
B2BModule,
|
||||
StorageModule,
|
||||
AuditLogModule,
|
||||
NotificationsModule,
|
||||
TariffsModule,
|
||||
ProductsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||
import { AuditLogService } from "./audit-log.service";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller("audit-logs")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles("SUPER_ADMIN", "ADMIN_EMPRESA")
|
||||
export class AuditLogController {
|
||||
constructor(private svc: AuditLogService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@CurrentUser() user: any,
|
||||
@Query("userId") userId?: string,
|
||||
@Query("action") action?: string,
|
||||
@Query("resource") resource?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
) {
|
||||
return this.svc.findAll({
|
||||
tenantId: user.role === "SUPER_ADMIN" ? undefined : user.tenantId,
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
from,
|
||||
to,
|
||||
page: page ? parseInt(page) : 1,
|
||||
limit: limit ? parseInt(limit) : 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuditLogService } from "./audit-log.service";
|
||||
import { AuditLogController } from "./audit-log.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AuditLogService],
|
||||
controllers: [AuditLogController],
|
||||
exports: [AuditLogService],
|
||||
})
|
||||
export class AuditLogModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export interface AuditLogEntry {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
action: string;
|
||||
resource?: string;
|
||||
resourceId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogService {
|
||||
private readonly logger = new Logger(AuditLogService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async log(entry: AuditLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.prisma.client.auditLog.create({ data: entry });
|
||||
} catch (e: unknown) {
|
||||
// Never let audit log failure break main flow
|
||||
this.logger.error("AuditLog write failed", (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(filters: {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
resource?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ data: any[]; total: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (filters.tenantId) where.tenantId = filters.tenantId;
|
||||
if (filters.userId) where.userId = filters.userId;
|
||||
if (filters.action) where.action = { contains: filters.action, mode: "insensitive" };
|
||||
if (filters.resource) where.resource = { contains: filters.resource, mode: "insensitive" };
|
||||
if (filters.from || filters.to) {
|
||||
where.createdAt = {};
|
||||
if (filters.from) where.createdAt.gte = new Date(filters.from);
|
||||
if (filters.to) where.createdAt.lte = new Date(filters.to);
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.client.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.client.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib"
|
||||
const TENANT_SLUG = "moraworld";
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
function buildSuiteAddress(suiteCode: string, config: import("@nestjs/config").ConfigService): string {
|
||||
const street = config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St");
|
||||
const city = config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange");
|
||||
const state = config.get("WAREHOUSE_ADDRESS_STATE", "NJ");
|
||||
const zip = config.get("WAREHOUSE_ADDRESS_ZIP", "07050");
|
||||
return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@@ -58,7 +66,7 @@ export class AuthService {
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
suiteCode,
|
||||
suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`,
|
||||
suiteAddress: buildSuiteAddress(suiteCode, this.config),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
@@ -106,7 +114,7 @@ export class AuthService {
|
||||
user: this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
address: buildSuiteAddress(suite.code, this.config),
|
||||
} : null,
|
||||
...tokens,
|
||||
};
|
||||
@@ -178,7 +186,7 @@ export class AuthService {
|
||||
...this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
address: buildSuiteAddress(suite.code, this.config),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:3000")
|
||||
.split(",")
|
||||
@@ -16,6 +18,10 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
// Serve uploaded files (photos, invoices) as static assets
|
||||
const uploadsDir = join(process.cwd(), "uploads");
|
||||
app.useStaticAssets(uploadsDir, { prefix: "/uploads" });
|
||||
|
||||
// Validación global de DTOs (class-validator)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** Called whenever a package status changes. Creates Notification records and stubs dispatch. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
const statusLabels: Record<string, string> = {
|
||||
REGISTRADO: "fue registrado en el sistema",
|
||||
EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ",
|
||||
RECIBIDO_BODEGA: "fue recibido en la bodega de NJ",
|
||||
EN_VERIFICACION: "está siendo verificado en bodega",
|
||||
VERIFICADO: "fue verificado. El cobro final fue aplicado.",
|
||||
DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)",
|
||||
EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador",
|
||||
EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador",
|
||||
LISTO_ENTREGA: "está listo para entrega",
|
||||
ENTREGADO: "fue entregado exitosamente",
|
||||
INCIDENCIA: "tiene una incidencia reportada",
|
||||
};
|
||||
|
||||
const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`;
|
||||
const body = `Tu paquete ${pkg.trackingId} ${label}.`;
|
||||
const subject = `Estado de tu paquete: ${pkg.trackingId}`;
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
await this.prisma.client.notification.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
userId: pkg.userId,
|
||||
channel,
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body,
|
||||
},
|
||||
});
|
||||
// STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc.
|
||||
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`);
|
||||
// Mark as sent (stub — in prod this would be async)
|
||||
await this.prisma.client.notification.updateMany({
|
||||
where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" },
|
||||
data: { status: "ENVIADO", sentAt: new Date() },
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20): Promise<any[]> {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findByPackage(packageId: string): Promise<any[]> {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { packageId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Post, Body, UseGuards } from "@nestjs/common";
|
||||
import { ProductsService } from "./products.service";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
|
||||
@Controller("products")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProductsController {
|
||||
constructor(private svc: ProductsService) {}
|
||||
|
||||
@Post("scan")
|
||||
scanUrl(@Body("url") url: string): Promise<{
|
||||
name: string;
|
||||
price: number;
|
||||
weightLb: number;
|
||||
imageUrl: string;
|
||||
store: string;
|
||||
url: string;
|
||||
}> {
|
||||
return this.svc.scanUrl(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProductsService } from "./products.service";
|
||||
import { ProductsController } from "./products.controller";
|
||||
|
||||
@Module({
|
||||
providers: [ProductsService],
|
||||
controllers: [ProductsController],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
interface ProductScanResult {
|
||||
name: string;
|
||||
price: number;
|
||||
weightLb: number;
|
||||
imageUrl: string;
|
||||
store: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
/**
|
||||
* Stub: In production this calls Amazon SP-API or a scraping service.
|
||||
* For now it extracts basic info from the URL and returns plausible mock data.
|
||||
*/
|
||||
async scanUrl(url: string): Promise<ProductScanResult> {
|
||||
const store = this.detectStore(url);
|
||||
|
||||
// Extract ASIN from Amazon URL if present
|
||||
const asinMatch = url.match(/\/dp\/([A-Z0-9]{10})/);
|
||||
const asin = asinMatch ? asinMatch[1] : null;
|
||||
|
||||
// Stub response — in prod: call Amazon SP-API Catalog Items API
|
||||
return {
|
||||
name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`,
|
||||
price: 29.99,
|
||||
weightLb: 1.5,
|
||||
imageUrl: "https://placehold.co/200x200?text=Product",
|
||||
store,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
private detectStore(url: string): string {
|
||||
if (url.includes("amazon.")) return "Amazon";
|
||||
if (url.includes("ebay.")) return "eBay";
|
||||
if (url.includes("walmart.")) return "Walmart";
|
||||
if (url.includes("target.")) return "Target";
|
||||
if (url.includes("bestbuy.")) return "Best Buy";
|
||||
return "Tienda online";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StorageService } from "./storage.service";
|
||||
|
||||
@Module({ providers: [StorageService], exports: [StorageService] })
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly uploadDir: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.uploadDir = path.join(process.cwd(), "uploads");
|
||||
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
|
||||
// ensure uploads dir exists
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise<string> {
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const dir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const filepath = path.join(dir, filename);
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
// Return URL relative to API base
|
||||
return `${this.baseUrl}/uploads/${subdir}/${filename}`;
|
||||
}
|
||||
|
||||
async deleteFile(url: string): Promise<void> {
|
||||
try {
|
||||
const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, "");
|
||||
const filepath = path.join(this.uploadDir, relative);
|
||||
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
||||
} catch (e: unknown) {
|
||||
this.logger.warn(`deleteFile failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Put, Body, UseGuards } from "@nestjs/common";
|
||||
import { TariffsService } from "./tariffs.service";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller("tariffs")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class TariffsController {
|
||||
constructor(private svc: TariffsService) {}
|
||||
|
||||
@Get()
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO")
|
||||
get(@CurrentUser() user: any) {
|
||||
return this.svc.findByTenant(user.tenantId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
update(@CurrentUser() user: any, @Body() dto: any) {
|
||||
return this.svc.update(user.tenantId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TariffsService } from "./tariffs.service";
|
||||
import { TariffsController } from "./tariffs.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [TariffsService],
|
||||
controllers: [TariffsController],
|
||||
exports: [TariffsService],
|
||||
})
|
||||
export class TariffsModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TariffsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findByTenant(tenantId: string): Promise<any> {
|
||||
let tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||
if (!tariff) {
|
||||
// Auto-create default tariff if not exists
|
||||
tariff = await this.prisma.client.tariff.create({
|
||||
data: { tenantId },
|
||||
});
|
||||
}
|
||||
return tariff;
|
||||
}
|
||||
|
||||
async update(tenantId: string, dto: Partial<{
|
||||
pricePerLb: number;
|
||||
insurancePct: number;
|
||||
fodinfaPct: number;
|
||||
ivaPct: number;
|
||||
max4x4Value: number;
|
||||
max4x4WeightKg: number;
|
||||
max4x4PerYear: number;
|
||||
}>): Promise<any> {
|
||||
const existing = await this.prisma.client.tariff.findUnique({ where: { tenantId } });
|
||||
if (!existing) {
|
||||
return this.prisma.client.tariff.create({ data: { tenantId, ...dto } });
|
||||
}
|
||||
return this.prisma.client.tariff.update({ where: { tenantId }, data: dto });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user