First Commit

This commit is contained in:
DillusDev001 2026-09-18 20:22:24 -04:00
commit f75db0e0d1
131 changed files with 12286 additions and 0 deletions

37
.env Normal file
View file

@ -0,0 +1,37 @@
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_NAME=paqueteria
# JWT
JWT_SECRET=paqueteria-secret-key-2026
JWT_EXPIRATION=15d
# API Key
API_KEY=3C03C2A5-F739-4353-9B7E-038A2113285A
# OCR Provider (tesseract | ocrspace | google-vision | mimo)
OCR_PROVIDER=mimo
# OCR.space (solo si OCR_PROVIDER=ocrspace)
OCRSPACE_API_KEY=
# mimo key
MIMO_API_KEY=tp-sb1of6tkbio6jlw5gbzpajnv0qz0c915p34bdgqi8nfl7jvo
# Google Vision (solo si OCR_PROVIDER=google-vision)
GOOGLE_VISION_API_KEY=AIzaSyB60ZqP92SKetK7IN2BY54EejJcxy8LEmk
# Worker
WORKER_KEY=885E2E87-6B8C-4CBC-93D8-D07CC5A13158
# App
PORT=3000
# Logo Empresa
EMPRESA_LOGO_MAX_SIZE=200
EMPRESA_LOGO_WIDTH=400
EMPRESA_LOGO_HEIGHT=400
EMPRESA_LOGO_QUALITY=80

38
.env.example Normal file
View file

@ -0,0 +1,38 @@
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_NAME=paqueteria
# JWT
JWT_SECRET=your-secret-key
JWT_EXPIRATION=7d
# API Key
API_KEY=your-api-key
# OCR Provider (tesseract | ocrspace | google-vision | mimo)
# tesseract: Gratis, local, precisa bien para texto impreso
# ocrspace: 25,000 req/mes gratis, mejor para manuscrito
# google-vision: Alta precisión, $1.50/1000 imágenes (1000 gratis/mes)
# mimo: Multimodal con mimo-v2.5, mejor para manuscrito, comprime >400KB automáticamente
OCR_PROVIDER=tesseract
# OCR.space (solo si OCR_PROVIDER=ocrspace)
# Obtener key gratis en: https://ocr.space/ocrapi/freekey
OCRSPACE_API_KEY=
# Google Vision (solo si OCR_PROVIDER=google-vision)
# Obtener key en: https://console.cloud.google.com/apis/credentials
GOOGLE_VISION_API_KEY=
# MiMo (solo si OCR_PROVIDER=mimo)
# Modelo multimodal mimo-v2.5 para OCR de etiquetas manuscritas
MIMO_API_KEY=
# Worker (clave para autenticar peticiones del worker)
WORKER_KEY=your-worker-key
# App
PORT=3000

86
.gitignore vendored Normal file
View file

@ -0,0 +1,86 @@
node_modules# Dependencies
node_modules/
node_modules
.pnp
.pnp.js
# Build output
dist/
build/
.next/
out/
# Testing
coverage/
# Environment
# .env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs/
*.log
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*.sublime-workspace
*.sublime-project
# Data and storage
data/
media/
!src/**/media/
uploads/
.wwebjs_auth/
.wwebjs_cache/
# Database
*.db
*.sqlite
*.sqlite3
# Docker
.docker/
# Misc
*.bak
*.tmp
*.temp
# Git worktrees
.worktrees/
# AI Agents
.playwright-mcp/
.remember/
.agent/
.claude/
.zcode/
.zcode-worktrees/
docs/plans/
docs/superpowers/
CLAUDE.md
_docs/
.superpowers/
# TypeScript incremental build cache (tsc writes this at repo root under the TS6 build config)
*.tsbuildinfo
node_modules

1
.npmrc Normal file
View file

@ -0,0 +1 @@
ignore-scripts=false

4
.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

114
README.md Normal file
View file

@ -0,0 +1,114 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Observability
In production applications, observability is essential for understanding how your system behaves, detecting issues early, and maintaining reliable performance.
[NestJS Observe](https://observe.nestjs.com) automatically instruments your NestJS application, giving you deep visibility into your system with minimal setup:
- **Distributed tracing:** Follow requests across services and understand how they flow through your system.
- **Waterfall analysis:** Visualize request execution and identify slow operations, bottlenecks, and unexpected delays.
- **Performance analysis:** Analyze application performance in real time and quickly pinpoint areas that need optimization.
- **Metrics:** Track key application and infrastructure metrics to understand system health and performance trends.
- **Logging:** Centralize and correlate logs with traces and other telemetry to make debugging easier.
- **Error tracking:** Detect errors quickly and investigate their root causes with the surrounding context.
- **SLA monitoring:** Track service-level objectives and identify when your application is approaching or exceeding defined thresholds.
- **Alarms and alerts:** Set up alerts for critical errors, performance degradation, SLA violations, and other anomalies so your team can react quickly.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Auto-instrument your application with [NestJS Observer](https://observer.nestjs.com). Distributed tracing, metrics, and logging made easy. Error tracking and performance monitoring for your NestJS applications.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

11
http/auth.http Normal file
View file

@ -0,0 +1,11 @@
@baseUrl = http://localhost:3000/v1
@contentType = application/json
### Login
POST {{baseUrl}}/auth/login
Content-Type: {{contentType}}
{
"usuario": "admin",
"password": "admin123"
}

4
http/environment.http Normal file
View file

@ -0,0 +1,4 @@
### Variables de entorno
@baseUrl = http://localhost:3000/v1
@contentType = application/json
@apiKey = 3C03C2A5-F739-4353-9B7E-038A2113285A

38
http/usuario.http Normal file
View file

@ -0,0 +1,38 @@
@baseUrl = http://localhost:3000/v1
@contentType = application/json
@apiKey = 3C03C2A5-F739-4353-9B7E-038A2113285A
### Listar usuarios
GET {{baseUrl}}/usuario
Authorization: Bearer {{token}}
### Obtener usuario por ID
GET {{baseUrl}}/usuario/1
Authorization: Bearer {{token}}
### Registrar usuario (requiere API Key)
POST {{baseUrl}}/usuario
Content-Type: {{contentType}}
x-api-key: {{apiKey}}
{
"nombre": "Juan Pérez",
"usuario": "juan",
"password": "password123",
"pregunta": "¿Cuál es tu color favorito?",
"respuesta": "azul",
"rol": "RECEPCION"
}
### Actualizar usuario
PUT {{baseUrl}}/usuario/1
Authorization: Bearer {{token}}
Content-Type: {{contentType}}
{
"nombre": "Juan Pérez Actualizado"
}
### Eliminar usuario
DELETE {{baseUrl}}/usuario/2
Authorization: Bearer {{token}}

8
nest-cli.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10
oxlint.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxc_linter/src/rules.rs",
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-floating-promises": "warn"
},
"env": {
"node": true
}
}

78
package.json Normal file
View file

@ -0,0 +1,78 @@
{
"name": "app-paqueteria-nest",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
"setup:db": "pnpm build && node dist/database/seeds/run.js",
"db:create": "npx typeorm-extension db:create --dataSource=typeorm.config.ts",
"db:drop": "npx typeorm-extension db:drop --dataSource=typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/jwt": "^12.0.1",
"@nestjs/passport": "^12.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/serve-static": "^12.0.0",
"@nestjs/swagger": "^12.0.1",
"@nestjs/typeorm": "^12.0.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.2",
"multer": "^2.3.0",
"openai": "^4.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.33.0",
"tesseract.js": "^5.1.0",
"typeorm": "^1.1.0",
"typeorm-extension": "^4.1.0",
"uuid": "^14.0.2"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"tsx": "^4.23.13",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"
}
}

5398
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,6 @@
allowBuilds:
'@scarf/scarf': true
bcrypt: true
esbuild: true
sharp: set this to true or false
tesseract.js: set this to true or false

BIN
spa.traineddata Normal file

Binary file not shown.

62
src/app.module.ts Normal file
View file

@ -0,0 +1,62 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { AuthModule } from './modules/auth/auth.module.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { EmpresaModule } from './modules/empresa/empresa.module.js';
import { SucursalModule } from './modules/sucursal/sucursal.module.js';
import { CategoriaModule } from './modules/categoria/categoria.module.js';
import { ZonaModule } from './modules/zona/zona.module.js';
import { FeriadoModule } from './modules/feriado/feriado.module.js';
import { PaqueteModule } from './modules/paquete/paquete.module.js';
import { MensajeModule } from './modules/mensaje/mensaje.module.js';
import { UploadModule } from './modules/upload/upload.module.js';
import { GuardsModule } from './shared/guards.module.js';
import { EmprendimientoModule } from './modules/emprendimiento/emprendimiento.module.js';
import { UsuarioModule } from './modules/usuario/usuario.module.js';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
port: config.get('DB_PORT'),
username: config.get('DB_USERNAME'),
password: config.get('DB_PASSWORD'),
database: config.get('DB_NAME'),
autoLoadEntities: true,
synchronize: true,
dropSchema: false,
}),
}),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
AuthModule,
EmpresaModule,
SucursalModule,
CategoriaModule,
ZonaModule,
FeriadoModule,
PaqueteModule,
MensajeModule,
UploadModule,
GuardsModule,
EmprendimientoModule,
UsuarioModule
],
})
export class AppModule { }

View file

@ -0,0 +1,38 @@
import { INestApplication } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Empresa } from '../../modules/empresa/entities/empresa.entity.js';
import { Usuario } from '../../modules/usuario/entities/usuario.entity.js';
export async function runSeed(app: INestApplication): Promise<void> {
const empresaRepo = app.get<Repository<Empresa>>(getRepositoryToken(Empresa));
const usuarioRepo = app.get<Repository<Usuario>>(getRepositoryToken(Usuario));
const existeEmpresa = await empresaRepo.findOne({ where: {} });
if (existeEmpresa) return;
console.log('DB vacía — ejecutando seed...');
/*
await empresaRepo.save({
nombre: 'La Clave',
diasIncluidos: 7,
costoBase: 5,
costoDiaAdicional: 2,
diasParaAlmacen: 30,
});
*/
const passwordHash = await bcrypt.hash('admin123', 10);
await usuarioRepo.save({
nombre: 'Administrador',
usuario: 'admin@admin.com',
password: passwordHash,
pregunta: '¿Cómo se llama tu mamá?',
respuesta: 'Juanita',
rol: 'ADMIN',
});
console.log('Seed completado: Empresa "La Clave" + usuario admin creados.');
}

View file

@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { Seeder } from 'typeorm-extension';
import { Empresa } from '../../modules/empresa/entities/empresa.entity.js';
export class EmpresaSeeder implements Seeder {
async run(dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(Empresa);
const existe = await repo.findOne({ where: {} });
if (existe) {
console.log('Empresa ya existe, omitiendo seed.');
return;
}
await repo.save({
nombre: 'La Clave',
diasIncluidos: 7,
costoBase: 5,
costoDiaAdicional: 2,
diasParaAlmacen: 30,
});
console.log('Empresa "La Clave" creada.');
}
}

View file

@ -0,0 +1,13 @@
import { DataSource } from 'typeorm';
import { Seeder } from 'typeorm-extension';
import { EmpresaSeeder } from './empresa.seeder.js';
import { UsuarioSeeder } from './usuario.seeder.js';
export default class MainSeeder implements Seeder {
async run(dataSource: DataSource): Promise<void> {
//await new EmpresaSeeder().run(dataSource);
await new UsuarioSeeder().run(dataSource);
console.log('Seed completado.');
}
}

36
src/database/seeds/run.ts Normal file
View file

@ -0,0 +1,36 @@
import 'reflect-metadata';
import 'dotenv/config';
import { DataSource } from 'typeorm';
import { EmpresaSeeder } from './empresa.seeder.js';
import { UsuarioSeeder } from './usuario.seeder.js';
import { Empresa } from '../../modules/empresa/entities/empresa.entity.js';
import { Usuario } from '../../modules/usuario/entities/usuario.entity.js';
const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 5432,
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'paqueteria',
entities: [Empresa, Usuario],
});
async function run() {
try {
await dataSource.initialize();
console.log('DB conectada.');
await new EmpresaSeeder().run(dataSource);
await new UsuarioSeeder().run(dataSource);
console.log('Seed completado.');
await dataSource.destroy();
} catch (error) {
console.error('Error en seed:', error);
await dataSource.destroy();
process.exit(1);
}
}
run();

View file

@ -0,0 +1,29 @@
import { DataSource } from 'typeorm';
import { Seeder } from 'typeorm-extension';
import { Usuario } from '../../modules/usuario/entities/usuario.entity.js';
import * as bcrypt from 'bcrypt';
export class UsuarioSeeder implements Seeder {
async run(dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(Usuario);
const existe = await repo.findOne({ where: { usuario: 'admin' } });
if (existe) {
console.log('Usuario admin ya existe, omitiendo seed.');
return;
}
const passwordHash = await bcrypt.hash('admin123', 10);
await repo.save({
nombre: 'Administrador',
usuario: 'admin@admin.com',
password: passwordHash,
pregunta: '¿Cómo se llama tu mamá?',
respuesta: 'Juanita',
rol: 'ADMIN',
});
console.log('Usuario admin creado.');
}
}

11
src/environments/environment.ts Executable file
View file

@ -0,0 +1,11 @@
export const environment = {
production: false,
tipoTramite: [
{ value: 'nombre_tramite', data: 'Nombre del Trámite' },
{ value: 'descripcion', data: 'Descripción' }
],
fases: [
{ value: 'nombre', data: 'Nombre de la Fase' },
{ value: 'descripcion', data: 'Descripción' }
]
};

44
src/main.ts Normal file
View file

@ -0,0 +1,44 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module.js';
import { ApiKeyGuard } from './shared/guards/api-key.guard.js';
import { ensureDatabase } from './shared/services/database.service.js';
import { runSeed } from './database/seeds/auto-seed.js';
async function bootstrap() {
await ensureDatabase();
const app = await NestFactory.create(AppModule);
await runSeed(app);
app.setGlobalPrefix('v1');
app.enableCors();
app.useGlobalGuards(app.get(ApiKeyGuard));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const config = new DocumentBuilder()
.setTitle('API Paquetería')
.setDescription('Sistema de Gestión de Paqueterías')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('v1/docs', app, document);
const port = process.env.PORT ?? 3000;
await app.listen(port, '0.0.0.0');
console.log(`Server running on http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/v1/docs`);
}
await bootstrap();

View file

@ -0,0 +1,36 @@
import { Controller, Post, Body, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LoginDto } from './dto/login.dto.js';
import { Public } from '../../shared/interfaces/auth.interface.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeAuth } from './auth.router.js';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@Public()
async login(@Body() dto: LoginDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeAuth.login.title,
routeAuth.login.route,
);
try {
const result = await this.authService.login(dto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Inicio de sesión exitoso';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
import { JwtStrategy } from '../../shared/strategies/jwt.strategy.js';
import { Usuario } from '../usuario/entities/usuario.entity.js';
@Module({
imports: [
TypeOrmModule.forFeature([Usuario]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRATION', '7d') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtStrategy, JwtModule],
})
export class AuthModule {}

View file

@ -0,0 +1,6 @@
export const routeAuth = {
login: {
route: '[POST].../v1/auth/login',
title: 'Login',
},
};

View file

@ -0,0 +1,97 @@
import { Injectable, UnauthorizedException, HttpException, HttpStatus } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Usuario } from '../usuario/entities/usuario.entity.js';
import { LoginDto } from './dto/login.dto.js';
interface LoginAttempt {
count: number;
blockedUntil: Date | null;
}
@Injectable()
export class AuthService {
private loginAttempts = new Map<string, LoginAttempt>();
private readonly MAX_ATTEMPTS = 5;
private readonly BLOCK_DURATION_MS = 60 * 60 * 1000; // 1 hora
constructor(
@InjectRepository(Usuario)
private usuarioRepo: Repository<Usuario>,
private jwtService: JwtService,
) {}
async login(dto: LoginDto) {
const attempts = this.loginAttempts.get(dto.usuario);
if (attempts?.blockedUntil && attempts.blockedUntil > new Date()) {
const remainingMs = attempts.blockedUntil.getTime() - Date.now();
const remainingMin = Math.ceil(remainingMs / 60000);
throw new HttpException(
`Cuenta bloqueada. Intenta de nuevo en ${remainingMin} minuto(s).`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
const usuario = await this.usuarioRepo.findOne({
where: { usuario: dto.usuario },
});
if (!usuario) {
this.trackFailedAttempt(dto.usuario);
throw new UnauthorizedException('Credenciales inválidas');
}
const passwordValid = await bcrypt.compare(dto.password, usuario.password);
if (!passwordValid) {
this.trackFailedAttempt(dto.usuario);
throw new UnauthorizedException('Credenciales inválidas');
}
this.loginAttempts.delete(dto.usuario);
const payload = {
sub: usuario.idUsuario,
usuario: usuario.usuario,
rol: usuario.rol,
};
return {
token: this.jwtService.sign(payload),
usuario: {
idUsuario: usuario.idUsuario,
nombre: usuario.nombre,
usuario: usuario.usuario,
rol: usuario.rol,
},
};
}
private trackFailedAttempt(username: string) {
const current = this.loginAttempts.get(username);
if (!current) {
this.loginAttempts.set(username, {
count: 1,
blockedUntil: null,
});
return;
}
const newCount = current.count + 1;
if (newCount >= this.MAX_ATTEMPTS) {
this.loginAttempts.set(username, {
count: newCount,
blockedUntil: new Date(Date.now() + this.BLOCK_DURATION_MS),
});
} else {
this.loginAttempts.set(username, {
count: newCount,
blockedUntil: null,
});
}
}
}

View file

@ -0,0 +1,14 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'admin' })
@IsString()
@IsNotEmpty()
usuario: string;
@ApiProperty({ example: 'password123' })
@IsString()
@IsNotEmpty()
password: string;
}

View file

@ -0,0 +1,132 @@
import { Controller, Get, Post, Put, Delete, Body, Param, HttpStatus } from '@nestjs/common';
import { CategoriaService } from './categoria.service.js';
import { CategoriaDto } from './dto/categoria.dto.js';
import { UpdateCategoriaDto } from './dto/update-categoria.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeCategoria } from './categoria.router.js';
@Controller('categoria')
export class CategoriaController {
constructor(private readonly categoriaService: CategoriaService) {}
@Get()
async findAll(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeCategoria.find.title,
routeCategoria.find.route,
);
try {
const result = await this.categoriaService.findAll();
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = `${result.length} Categoria(s) encontrado(s).`;
apiResult.boolean = true;
apiResult.rows = result.length;
apiResult.data = result;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get(':idCategoria')
async findOne(@Param('idCategoria') idCategoria: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeCategoria.one.title,
routeCategoria.one.route,
);
try {
const result = await this.categoriaService.findOne(idCategoria);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Existe un Categoria.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post()
async create(@Body() categoriaDto: CategoriaDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeCategoria.create.title,
routeCategoria.create.route,
);
try {
const result = await this.categoriaService.create(categoriaDto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Categoria se ha agregado correctamente.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Put(':idCategoria')
async update(
@Param('idCategoria') idCategoria: string,
@Body() updateCategoriaDto: UpdateCategoriaDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeCategoria.update.title,
routeCategoria.update.route,
);
try {
const result = await this.categoriaService.update(idCategoria, updateCategoriaDto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Se ha actualizado correctamente.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Delete(':idCategoria')
async remove(@Param('idCategoria') idCategoria: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeCategoria.remove.title,
routeCategoria.remove.route,
);
try {
await this.categoriaService.remove(idCategoria);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Se ha eliminado correctamente.';
apiResult.boolean = true;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CategoriaController } from './categoria.controller.js';
import { CategoriaService } from './categoria.service.js';
import { Categoria } from './entities/categoria.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Categoria])],
controllers: [CategoriaController],
providers: [CategoriaService],
exports: [CategoriaService],
})
export class CategoriaModule {}

View file

@ -0,0 +1,26 @@
export const routeCategoria = {
multiple: {
route: '[POST].../v1/categoria/multiple',
title: 'Create Multiple Categoria',
},
create: {
route: '[POST].../v1/categoria',
title: 'Create Categoria',
},
one: {
route: '[GET].../v1/categoria/:idCategoria',
title: 'Find One Categoria',
},
find: {
route: '[POST].../v1/categoria/find',
title: 'Find Categoria WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/categoria/:idCategoria',
title: 'Update Categoria',
},
remove: {
route: '[DELETE].../v1/categoria/:idCategoria',
title: 'Remove Categoria',
},
};

View file

@ -0,0 +1,55 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Categoria } from './entities/categoria.entity.js';
import { CategoriaDto } from './dto/categoria.dto.js';
import { UpdateCategoriaDto } from './dto/update-categoria.dto.js';
@Injectable()
export class CategoriaService {
constructor(
@InjectRepository(Categoria)
private repo: Repository<Categoria>,
) {}
async findAll() {
return this.repo.find();
}
async findOne(id: string) {
const categoria = await this.repo.findOne({ where: { idCategoria: id } });
if (!categoria) throw new NotFoundException('Categoría no encontrada');
return categoria;
}
async create(dto: CategoriaDto) {
const exists = await this.repo.findOne({ where: { nombre: dto.nombre } });
if (exists) throw new ConflictException('Ya existe una categoría con ese nombre');
const categoria = this.repo.create(dto);
const saved = await this.repo.save(categoria);
return saved;
}
async update(id: string, dto: UpdateCategoriaDto) {
const categoria = await this.repo.findOne({ where: { idCategoria: id } });
if (!categoria) throw new NotFoundException('Categoría no encontrada');
if (dto.nombre) {
const exists = await this.repo.findOne({ where: { nombre: dto.nombre } });
if (exists && exists.idCategoria !== id) {
throw new ConflictException('Ya existe otra categoría con ese nombre');
}
}
Object.assign(categoria, dto);
const saved = await this.repo.save(categoria);
return saved;
}
async remove(id: string) {
const categoria = await this.repo.findOne({ where: { idCategoria: id } });
if (!categoria) throw new NotFoundException('Categoría no encontrada');
await this.repo.remove(categoria);
}
}

View file

@ -0,0 +1,14 @@
import { IsString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CategoriaDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idCategoria: string;
@ApiProperty({ example: 'Electrónica' })
@IsString()
@IsNotEmpty()
nombre: string;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CategoriaDto } from './categoria.dto.js';
export class UpdateCategoriaDto extends PartialType(CategoriaDto) {}

View file

@ -0,0 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Categoria {
@PrimaryGeneratedColumn('uuid')
idCategoria: string;
@Column()
nombre: string;
}

View file

@ -0,0 +1,28 @@
import { IsString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class EmprendimientoDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idEmprendimiento?: string;
@ApiProperty({ example: 'Mi Emprendimiento' })
@IsString()
@IsNotEmpty()
emprendimiento?: string;
@ApiProperty({ example: 'Juan Perez' })
@IsString()
propietario?: string;
@ApiProperty({ example: '591' })
@IsString()
@IsNotEmpty()
codigoPais: string; // Solo dígitos, sin +
@ApiProperty({ example: '70123456' })
@IsString()
@IsNotEmpty()
whatsapp: string;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { EmprendimientoDto } from './emprendimiento.dto.js';
export class UpdateEmprendimientoDto extends PartialType(EmprendimientoDto) {}

View file

@ -0,0 +1,176 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, HttpStatus } from '@nestjs/common';
import { EmprendimientoService } from './emprendimiento.service.js';
import { EmprendimientoDto } from './dto/emprendimiento.dto.js';
import { UpdateEmprendimientoDto } from './dto/update-emprendimiento.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { FindDto } from '../../shared/class/find.dto.js';
import { routeEmprendimiento } from './emprendimiento.router.js';
@Controller('emprendimiento')
export class EmprendimientoController {
constructor(private readonly emprendimientoService: EmprendimientoService) { }
@Post()
async create(@Body() emprendimientoDto: EmprendimientoDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmprendimiento.create.title,
routeEmprendimiento.create.route,
);
try {
const result = await this.emprendimientoService.create(emprendimientoDto);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get(':idEmprendimiento')
async findOne(@Param('idEmprendimiento') idEmprendimiento: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmprendimiento.one.title,
routeEmprendimiento.one.route,
);
try {
const result = await this.emprendimientoService.findOne(idEmprendimiento);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post('find')
async find(@Body() findByDto: FindDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmprendimiento.find.title,
routeEmprendimiento.find.route,
);
try {
const {
orderByDirection,
orderByAttribute,
whereConditions,
limit,
offset,
} = findByDto;
const result = await this.emprendimientoService.find(
whereConditions,
orderByAttribute,
orderByDirection,
limit,
offset,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Patch(':idEmprendimiento')
async update(
@Param('idEmprendimiento') idEmprendimiento: number,
@Body() updateEmprendimientoDto: UpdateEmprendimientoDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmprendimiento.update.title,
routeEmprendimiento.update.route,
);
try {
const result = await this.emprendimientoService.update(
idEmprendimiento,
updateEmprendimientoDto,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Delete(':idEmprendimiento')
async remove(@Param('idEmprendimiento') idEmprendimiento: number): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmprendimiento.remove.title,
routeEmprendimiento.remove.route,
);
try {
const result = await this.emprendimientoService.remove(idEmprendimiento);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmprendimientoService } from './emprendimiento.service.js';
import { EmprendimientoController } from './emprendimiento.controller.js';
import { Emprendimiento } from './entities/emprendimiento.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Emprendimiento])],
controllers: [EmprendimientoController],
providers: [EmprendimientoService],
exports: [EmprendimientoService],
})
export class EmprendimientoModule {}

View file

@ -0,0 +1,26 @@
export const routeEmprendimiento = {
multiple: {
route: '[POST].../v1/emprendimiento/multiple',
title: 'Create Multiple Emprendimiento',
},
create: {
route: '[POST].../v1/emprendimiento',
title: 'Create Emprendimiento',
},
one: {
route: '[GET].../v1/emprendimiento/:idEmprendimiento',
title: 'Find One Emprendimiento',
},
find: {
route: '[POST].../v1/emprendimiento/find',
title: 'Find Emprendimiento WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/emprendimiento/:idEmprendimiento',
title: 'Update Emprendimiento',
},
remove: {
route: '[DELETE].../v1/emprendimiento/:idEmprendimiento',
title: 'Remove Emprendimiento',
},
};

View file

@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EmprendimientoDto } from './dto/emprendimiento.dto.js';
import { UpdateEmprendimientoDto } from './dto/update-emprendimiento.dto.js';
import { defaultServiceResult, ServiceResult } from '../../shared/interfaces/service.result.js';
import { WhereCondition } from '../../shared/type/query-builder.types.js';
import { Emprendimiento } from './entities/emprendimiento.entity.js';
@Injectable()
export class EmprendimientoService {
constructor(
@InjectRepository(Emprendimiento)
private emprendimientoRepository: Repository<Emprendimiento>,
) { }
async create(emprendimientoDto: EmprendimientoDto): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = this.emprendimientoRepository.create(emprendimientoDto);
await this.emprendimientoRepository.save(result);
serviceResult.boolean = true;
serviceResult.message = 'Emprendimiento se ha agregado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async findOne(idEmprendimiento: string): Promise<ServiceResult> {
let serviceResult = {
boolean: false,
message: '',
number: 0,
object: null,
data: null,
} as ServiceResult;
const result = await this.emprendimientoRepository.findOneBy({ idEmprendimiento });
if (result) {
serviceResult.boolean = true;
serviceResult.message = 'Existe un Emprendimiento.';
serviceResult.number = 1;
serviceResult.object = result;
} else {
serviceResult.message = 'No existe Emprendimiento.';
}
return serviceResult;
}
async find(
whereConditions: WhereCondition[],
orderByAttribute: string,
orderByDirection: 'ASC' | 'DESC',
limit: number,
offset: number,
): Promise<ServiceResult> {
const serviceResult = defaultServiceResult();
try {
let query = this.emprendimientoRepository.createQueryBuilder('emprendimiento');
whereConditions.forEach((condition, index) => {
const { attribute, value, operator = '=' } = condition;
const paramKey = `param${index}`;
const param = {
[paramKey]: operator === 'like' ? `%${value}%` : value,
};
const whereMethod = index === 0 ? 'where' : 'andWhere';
query = query[whereMethod](
`emprendimiento.${attribute} ${operator} :${paramKey}`,
param,
);
});
query = query.orderBy(`emprendimiento.${orderByAttribute}`, orderByDirection);
const totalRegistros = await query.getCount();
const skip = (offset - 1) * limit;
query = query.skip(skip).take(limit);
const result = await query.getMany();
const count = result.length;
serviceResult.boolean = count > 0;
serviceResult.message = `${count} Emprendimiento(s) encontrado(s).`;
serviceResult.number = totalRegistros;
serviceResult.data = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async update(
idEmprendimiento: number,
updateEmprendimientoDto: UpdateEmprendimientoDto,
): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.emprendimientoRepository.update(
idEmprendimiento,
updateEmprendimientoDto,
);
serviceResult.boolean = result.affected === 1 ? true : false;
serviceResult.message = 'Se ha actualizado correctamente.';
serviceResult.number = result.affected || 0;
serviceResult.object = result;
return serviceResult;
}
async remove(idEmprendimiento: number): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.emprendimientoRepository.delete(idEmprendimiento);
serviceResult.boolean = result.affected === 1 ? true : false;
serviceResult.message =
result.affected === 1
? 'Se ha eliminado correctamente.'
: 'No se ha encontrado el Emprendimiento.';
serviceResult.number = result.affected || 0;
return serviceResult;
}
}

View file

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Emprendimiento {
@PrimaryGeneratedColumn('uuid')
idEmprendimiento: string;
@Column()
emprendimiento: string;
@Column({ nullable: true })
propietario: string;
@Column()
codigoPais: string;
@Column()
whatsapp: string;
}

View file

@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
export class EmpresaDto {
@IsNotEmpty()
@ApiProperty({ example: 'La Clave' })
@IsString()
nombre: string;
@IsNotEmpty()
@ApiProperty({ example: 5 })
@IsNumber()
@Type(() => Number)
diasIncluidos: number;
@IsNotEmpty()
@ApiProperty({ example: 5.00 })
@IsNumber()
@Type(() => Number)
costoBase: number;
@IsNotEmpty()
@ApiProperty({ example: 2.00 })
@IsNumber()
@Type(() => Number)
costoDiaAdicional: number;
@IsNotEmpty()
@ApiProperty({ example: 15 })
@IsNumber()
@Type(() => Number)
diasParaAlmacen: number;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { EmpresaDto } from './empresa.dto.js';
export class UpdateEmpresaDto extends PartialType(EmpresaDto) { }

View file

@ -0,0 +1,107 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseInterceptors, UploadedFile, HttpStatus } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { EmpresaService } from './empresa.service.js';
import { EmpresaDto } from './dto/empresa.dto.js';
import { UpdateEmpresaDto } from './dto/update-empresa.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeEmpresa } from './empresa.router.js';
@Controller('empresa')
export class EmpresaController {
constructor(private readonly service: EmpresaService) {}
@Get()
async findOne(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmpresa.one.title,
routeEmpresa.one.route,
);
try {
const result = await this.service.findOne();
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
@Post()
@UseInterceptors(FileInterceptor('logo'))
async create(
@Body() dto: EmpresaDto,
@UploadedFile() logo?: Express.Multer.File,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmpresa.create.title,
routeEmpresa.create.route,
);
try {
const result = await this.service.create(dto, logo);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
@Patch()
@UseInterceptors(FileInterceptor('logo'))
async update(
@Body() dto: UpdateEmpresaDto,
@UploadedFile() logo?: Express.Multer.File,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeEmpresa.update.title,
routeEmpresa.update.route,
);
try {
const result = await this.service.update(dto, logo);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
}

View file

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MulterModule } from '@nestjs/platform-express';
import multer from 'multer';
import { EmpresaController } from './empresa.controller.js';
import { EmpresaService } from './empresa.service.js';
import { Empresa } from './entities/empresa.entity.js';
@Module({
imports: [
TypeOrmModule.forFeature([Empresa]),
MulterModule.register({ storage: multer.memoryStorage() }),
],
controllers: [EmpresaController],
providers: [EmpresaService],
exports: [EmpresaService],
})
export class EmpresaModule {}

View file

@ -0,0 +1,26 @@
export const routeEmpresa = {
multiple: {
route: '[POST].../v1/empresa/multiple',
title: 'Create Multiple Empresa',
},
create: {
route: '[POST].../v1/empresa',
title: 'Create Empresa',
},
one: {
route: '[GET].../v1/empresa/:idEmpresa',
title: 'Find One Empresa',
},
find: {
route: '[POST].../v1/empresa/find',
title: 'Find Empresa WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/empresa/:idEmpresa',
title: 'Update Empresa',
},
remove: {
route: '[DELETE].../v1/empresa/:idEmpresa',
title: 'Remove Empresa',
},
};

View file

@ -0,0 +1,193 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { Empresa } from './entities/empresa.entity.js';
import { EmpresaDto } from './dto/empresa.dto.js';
import { UpdateEmpresaDto } from './dto/update-empresa.dto.js';
import { defaultServiceResult, ServiceResult } from '../../shared/interfaces/service.result.js';
import { WhereCondition } from '../../shared/type/query-builder.types.js';
@Injectable()
export class EmpresaService {
private uploadsPath = path.join(process.cwd(), 'uploads', 'empresas');
constructor(
@InjectRepository(Empresa)
private repo: Repository<Empresa>,
private configService: ConfigService,
) {
fs.mkdirSync(this.uploadsPath, { recursive: true });
}
async findOne(): Promise<ServiceResult> {
let serviceResult = {
boolean: false,
message: '',
number: 0,
object: null,
data: null,
} as ServiceResult;
const result = await this.repo.findOne({ where: {} });
if (result) {
serviceResult.boolean = true;
serviceResult.message = 'Existe una Empresa.';
serviceResult.number = 1;
serviceResult.object = result;
} else {
serviceResult.message = 'No existe Empresa.';
}
return serviceResult;
}
async create(dto: EmpresaDto, logo?: Express.Multer.File): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const count = await this.repo.count();
if (count > 0) {
serviceResult.message = 'Ya existe una empresa registrada. Solo se permite una empresa por instancia.';
return serviceResult;
}
let logoUrl: string | undefined;
if (logo) {
logoUrl = await this.processLogo(logo);
}
const result = this.repo.create({ ...dto, logoUrl });
await this.repo.save(result);
serviceResult.boolean = true;
serviceResult.message = 'Empresa se ha agregado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async update(dto: UpdateEmpresaDto, logo?: Express.Multer.File): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const empresa = await this.repo.findOne({ where: {} });
if (!empresa) {
serviceResult.message = 'Empresa no encontrada.';
return serviceResult;
}
if (logo) {
if (empresa.logoUrl) {
const oldPath = path.join(process.cwd(), empresa.logoUrl);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
empresa.logoUrl = await this.processLogo(logo);
}
Object.assign(empresa, dto);
const result = await this.repo.save(empresa);
serviceResult.boolean = true;
serviceResult.message = 'Se ha actualizado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async find(
whereConditions: WhereCondition[],
orderByAttribute: string,
orderByDirection: 'ASC' | 'DESC',
limit: number,
offset: number,
): Promise<ServiceResult> {
const serviceResult = defaultServiceResult();
try {
let query = this.repo.createQueryBuilder('empresa');
whereConditions.forEach((condition, index) => {
const { attribute, value, operator = '=' } = condition;
const paramKey = `param${index}`;
const param = {
[paramKey]: operator === 'like' ? `%${value}%` : value,
};
const whereMethod = index === 0 ? 'where' : 'andWhere';
query = query[whereMethod](
`empresa.${attribute} ${operator} :${paramKey}`,
param,
);
});
query = query.orderBy(`empresa.${orderByAttribute}`, orderByDirection);
const totalRegistros = await query.getCount();
const skip = (offset - 1) * limit;
query = query.skip(skip).take(limit);
const result = await query.getMany();
const count = result.length;
serviceResult.boolean = count > 0;
serviceResult.message = `${count} Empresa(s) encontrado(s).`;
serviceResult.number = totalRegistros;
serviceResult.data = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
private async processLogo(file: Express.Multer.File): Promise<string> {
this.validateFile(file);
const maxSizeKB = Number(this.configService.get('EMPRESA_LOGO_MAX_SIZE', 200));
const width = Number(this.configService.get('EMPRESA_LOGO_WIDTH', 400));
const height = Number(this.configService.get('EMPRESA_LOGO_HEIGHT', 400));
const quality = Number(this.configService.get('EMPRESA_LOGO_QUALITY', 80));
let buffer = file.buffer;
let currentQuality = quality;
buffer = await sharp(file.buffer)
.resize({ width, height, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: currentQuality })
.toBuffer();
while (buffer.length > maxSizeKB * 1024 && currentQuality > 10) {
currentQuality -= 10;
buffer = await sharp(file.buffer)
.resize({ width, height, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: currentQuality })
.toBuffer();
}
const filename = `${uuidv4()}.jpg`;
const filePath = path.join(this.uploadsPath, filename);
fs.writeFileSync(filePath, buffer);
return `/uploads/empresas/${filename}`;
}
private validateFile(file: Express.Multer.File): void {
if (!file) return;
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedMimes.includes(file.mimetype)) {
throw new Error('Formato de archivo no soportado. Use JPEG, PNG o WebP');
}
}
}

View file

@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Empresa {
@PrimaryGeneratedColumn('uuid')
idEmpresa: string;
@Column()
nombre: string;
@Column({ nullable: true })
logoUrl: string;
@Column({ type: 'int' })
diasIncluidos: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
costoBase: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
costoDiaAdicional: number;
@Column({ type: 'int' })
diasParaAlmacen: number;
}

View file

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsDateString, IsUUID, IsOptional } from 'class-validator';
export class FeriadoDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idFeriado: string;
@IsNotEmpty()
@IsDateString()
@ApiProperty({ example: '2026-01-01' })
fecha: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'Año Nuevo' })
nombre: string;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { FeriadoDto } from './feriado.dto.js';
export class UpdateFeriadoDto extends PartialType(FeriadoDto) { }

View file

@ -0,0 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Feriado {
@PrimaryGeneratedColumn('uuid')
idFeriado: string;
@Column({ type: 'date' })
fecha: Date;
@Column()
nombre: string;
}

View file

@ -0,0 +1,190 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, HttpStatus } from '@nestjs/common';
import { FeriadoService } from './feriado.service.js';
import { FeriadoDto } from './dto/feriado.dto.js';
import { UpdateFeriadoDto } from './dto/update-feriado.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeFeriado } from './feriado.router.js';
@Controller('feriado')
export class FeriadoController {
constructor(private readonly feriadoService: FeriadoService) { }
@Get()
async findAll(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.find.title,
routeFeriado.find.route,
);
try {
const result = await this.feriadoService.findAll();
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get(':idFeriado')
async findOne(@Param('idFeriado') idFeriado: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.one.title,
routeFeriado.one.route,
);
try {
const result = await this.feriadoService.findOne(idFeriado);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error interno del servidor';
}
return apiResult;
}
@Post()
async create(@Body() feriadoDto: FeriadoDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.create.title,
routeFeriado.create.route,
);
try {
const result = await this.feriadoService.create(feriadoDto);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error interno del servidor';
}
return apiResult;
}
@Post('generar/:year')
async generar(@Param('year') year: number): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.create.title,
routeFeriado.create.route,
);
try {
const result = await this.feriadoService.generar(year);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error interno del servidor';
}
return apiResult;
}
@Patch(':idFeriado')
async update(
@Param('idFeriado') idFeriado: string,
@Body() updateFeriadoDto: UpdateFeriadoDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.update.title,
routeFeriado.update.route,
);
try {
const result = await this.feriadoService.update(
idFeriado,
updateFeriadoDto,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error interno del servidor';
}
return apiResult;
}
@Delete(':idFeriado')
async remove(@Param('idFeriado') idFeriado: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeFeriado.remove.title,
routeFeriado.remove.route,
);
try {
const result = await this.feriadoService.remove(idFeriado);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error interno del servidor';
}
return apiResult;
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeriadoController } from './feriado.controller.js';
import { FeriadoService } from './feriado.service.js';
import { Feriado } from './entities/feriado.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Feriado])],
controllers: [FeriadoController],
providers: [FeriadoService],
exports: [FeriadoService],
})
export class FeriadoModule {}

View file

@ -0,0 +1,26 @@
export const routeFeriado = {
multiple: {
route: '[POST].../v1/feriado/multiple',
title: 'Create Multiple Feriado',
},
create: {
route: '[POST].../v1/feriado',
title: 'Create Feriado',
},
one: {
route: '[GET].../v1/feriado/:idFeriado',
title: 'Find One Feriado',
},
find: {
route: '[POST].../v1/feriado/find',
title: 'Find Feriado WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/feriado/:idFeriado',
title: 'Update Feriado',
},
remove: {
route: '[DELETE].../v1/feriado/:idFeriado',
title: 'Remove Feriado',
},
};

View file

@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import { FeriadoDto } from './dto/feriado.dto.js';
import { UpdateFeriadoDto } from './dto/update-feriado.dto.js';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { defaultServiceResult, ServiceResult } from '../../shared/interfaces/service.result.js';
import { Feriado } from './entities/feriado.entity.js';
import { generarFeriadosBolivia } from '../../shared/utils/feriados.util.js';
@Injectable()
export class FeriadoService {
constructor(
@InjectRepository(Feriado)
private feriadoRepository: Repository<Feriado>,
) { }
async findAll(): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.feriadoRepository.find({ order: { fecha: 'ASC' } });
serviceResult.boolean = result.length > 0;
serviceResult.message = result.length > 0
? `${result.length} feriado(s) encontrado(s).`
: 'No se encontraron feriados.';
serviceResult.number = result.length;
serviceResult.data = result;
return serviceResult;
}
async findOne(idFeriado: string): Promise<ServiceResult> {
let serviceResult = {
boolean: false,
message: '',
number: 0,
object: null,
data: null,
} as ServiceResult;
const result = await this.feriadoRepository.findOneBy({ idFeriado });
if (result) {
serviceResult.boolean = true;
serviceResult.message = 'Existe un Feriado.';
serviceResult.number = 1;
serviceResult.object = result;
} else {
serviceResult.message = 'No existe Feriado.';
}
return serviceResult;
}
async create(feriadoDto: FeriadoDto): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const fechaDate = new Date(feriadoDto.fecha + 'T00:00:00');
const exists = await this.feriadoRepository.findOne({ where: { fecha: fechaDate } });
if (exists) {
serviceResult.message = 'Ya existe un feriado en esa fecha.';
return serviceResult;
}
const result = this.feriadoRepository.create({ ...feriadoDto, fecha: fechaDate });
await this.feriadoRepository.save(result);
serviceResult.boolean = true;
serviceResult.message = 'Feriado se ha agregado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async generar(year: number): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
try {
const feriadosGenerados = generarFeriadosBolivia(year);
const existentes = await this.feriadoRepository
.createQueryBuilder('f')
.where('EXTRACT(YEAR FROM f.fecha) = :year', { year })
.getMany();
const existentesFechas = new Set(existentes.map((f) => new Date(f.fecha).toISOString().split('T')[0]));
const nuevos = feriadosGenerados.filter((f) => !existentesFechas.has(f.fecha));
if (nuevos.length === 0) {
serviceResult.message = `Ya existen feriados registrados para el año ${year}`;
return serviceResult;
}
const feriados = this.feriadoRepository.create(
nuevos.map((f) => ({
fecha: new Date(f.fecha + 'T00:00:00'),
nombre: f.nombre,
})),
);
const saved = await this.feriadoRepository.save(feriados);
serviceResult.boolean = true;
serviceResult.message = `Se generaron ${saved.length} feriados para el año ${year}`;
serviceResult.number = saved.length;
serviceResult.data = saved;
} catch (error: any) {
serviceResult.message = `Error al generar feriados: ${error.message}`;
}
return serviceResult;
}
async update(
idFeriado: string,
updateFeriadoDto: UpdateFeriadoDto,
): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const feriado = await this.feriadoRepository.findOneBy({ idFeriado });
if (!feriado) {
serviceResult.message = 'No existe Feriado.';
return serviceResult;
}
if (updateFeriadoDto.fecha) {
const fechaDate = new Date(updateFeriadoDto.fecha + 'T00:00:00');
const fechaActual = new Date(feriado.fecha);
const fechaStr = fechaActual.toISOString().split('T')[0];
if (updateFeriadoDto.fecha !== fechaStr) {
const exists = await this.feriadoRepository.findOne({ where: { fecha: fechaDate } });
if (exists) {
serviceResult.message = 'Ya existe un feriado en esa fecha.';
return serviceResult;
}
}
Object.assign(feriado, { ...updateFeriadoDto, fecha: fechaDate });
} else {
Object.assign(feriado, updateFeriadoDto);
}
const result = await this.feriadoRepository.save(feriado);
serviceResult.boolean = true;
serviceResult.message = 'Se ha actualizado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async remove(idFeriado: string): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.feriadoRepository.delete(idFeriado);
serviceResult.boolean = result.affected === 1 ? true : false;
serviceResult.message =
result.affected === 1
? 'Se ha eliminado correctamente.'
: 'No se ha encontrado el Feriado.';
serviceResult.number = result.affected || 0;
return serviceResult;
}
}

View file

@ -0,0 +1,47 @@
import { IsString, IsNotEmpty, IsEnum, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class MensajeWhatsAppDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idMensajeWhatsApp?: string;
@ApiProperty({ example: 'uuid-paquete' })
@IsString()
idPaquete: string;
@ApiProperty({ example: '59170123456' })
@IsString()
@IsNotEmpty()
telefonoDestino: string;
@ApiProperty({ enum: ['RECEPCION_PAQUETE', 'PAQUETE_ENTREGADO'] })
@IsEnum(['RECEPCION_PAQUETE', 'PAQUETE_ENTREGADO'])
tipo: string;
@ApiProperty({ example: 'Tu paquete S1-0001-A está listo para recoger' })
@IsString()
@IsNotEmpty()
mensaje1: string;
@ApiPropertyOptional({ example: 'Encuéntranos en Av. Principal #123' })
@IsString()
@IsOptional()
mensaje2?: string;
@ApiPropertyOptional({ example: 'Horarios: 08:00 - 18:00' })
@IsString()
@IsOptional()
mensaje3?: string;
@ApiPropertyOptional({ example: '/uploads/qr/abc.png' })
@IsString()
@IsOptional()
urlQR?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
sessionId?: string;
}

View file

@ -0,0 +1,19 @@
import { IsString, IsNotEmpty, IsOptional, IsDateString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateEstadoDto {
@ApiProperty({ example: 'ENVIADO' })
@IsString()
@IsNotEmpty()
estado: string;
@ApiPropertyOptional({ example: 'Error de conexión' })
@IsString()
@IsOptional()
error?: string;
@ApiPropertyOptional()
@IsDateString()
@IsOptional()
fechaEnvio?: string;
}

View file

@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity()
export class MensajeWhatsApp {
@PrimaryGeneratedColumn('uuid')
idMensajeWhatsApp: string;
@Column()
idPaquete: string;
@Column()
telefonoDestino: string;
@Column({ type: 'enum', enum: ['RECEPCION_PAQUETE', 'PAQUETE_ENTREGADO', 'NOTIFICACION_EMPRENDEDOR'] })
tipo: string;
// Mensajes (RECEPCION_PAQUETE usa los 3, PAQUETE_ENTREGADO solo mensaje1)
@Column({ type: 'text' })
mensaje1: string;
@Column({ type: 'text', nullable: true })
mensaje2: string;
@Column({ type: 'text', nullable: true })
mensaje3: string;
// QR (solo RECEPCION_PAQUETE)
@Column({ nullable: true })
urlQR: string;
@Column({ type: 'enum', enum: ['PENDIENTE', 'ENVIANDO', 'ENVIADO', 'ERROR'], default: 'PENDIENTE' })
estado: string;
@Column({ type: 'int', default: 0 })
intentos: number;
@Column({ type: 'text', nullable: true })
error: string;
@Column({ nullable: true })
sessionId: string;
@CreateDateColumn()
fechaCreacion: Date;
@Column({ type: 'timestamp', nullable: true })
fechaEnvio: Date;
}

View file

@ -0,0 +1,182 @@
import { Controller, Get, Post, Put, Body, Param, Query, HttpStatus } from '@nestjs/common';
import { MensajeService } from './mensaje.service.js';
import { MensajeWhatsAppDto } from './dto/mensaje.dto.js';
import { UpdateEstadoDto } from './dto/update-estado.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { FindDto } from '../../shared/class/find.dto.js';
import { routeMensaje } from './mensaje.router.js';
import { Public } from '../../shared/interfaces/auth.interface.js';
import { RequireApiKey } from '../../shared/interfaces/api-key.interface.js';
@Controller('mensaje')
export class MensajeController {
constructor(private readonly service: MensajeService) {}
@Get('paquete/:idPaquete')
async findByPaquete(@Param('idPaquete') idPaquete: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeMensaje.find.title,
routeMensaje.find.route,
);
try {
const result = await this.service.findByPaquete(idPaquete);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get('pendientes')
@Public()
@RequireApiKey()
async findPendientes(
@Query('limit') limit?: number,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeMensaje.find.title,
routeMensaje.find.route,
);
try {
const result = await this.service.findPendientes(limit || 5);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post()
async create(@Body() dto: MensajeWhatsAppDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeMensaje.create.title,
routeMensaje.create.route,
);
try {
const result = await this.service.create(dto);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post('find')
async find(@Body() findByDto: FindDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeMensaje.find.title,
routeMensaje.find.route,
);
try {
const {
orderByDirection,
orderByAttribute,
whereConditions,
limit,
offset,
} = findByDto;
const result = await this.service.find(
whereConditions,
orderByAttribute,
orderByDirection,
limit,
offset,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Put(':id/estado')
@Public()
@RequireApiKey()
async updateEstado(
@Param('id') id: string,
@Body() dto: UpdateEstadoDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeMensaje.update.title,
routeMensaje.update.route,
);
try {
const result = await this.service.updateEstado(id, dto);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MensajeController } from './mensaje.controller.js';
import { MensajeService } from './mensaje.service.js';
import { MensajeWhatsApp } from './entities/mensaje.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([MensajeWhatsApp])],
controllers: [MensajeController],
providers: [MensajeService],
exports: [MensajeService],
})
export class MensajeModule {}

View file

@ -0,0 +1,26 @@
export const routeMensaje = {
multiple: {
route: '[POST].../v1/mensaje/multiple',
title: 'Create Multiple Mensaje',
},
create: {
route: '[POST].../v1/mensaje',
title: 'Create Mensaje',
},
one: {
route: '[GET].../v1/mensaje/:idMensajeWhatsApp',
title: 'Find One Mensaje',
},
find: {
route: '[POST].../v1/mensaje/find',
title: 'Find Mensaje WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/mensaje/:idMensajeWhatsApp',
title: 'Update Mensaje',
},
remove: {
route: '[DELETE].../v1/mensaje/:idMensajeWhatsApp',
title: 'Remove Mensaje',
},
};

View file

@ -0,0 +1,187 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MensajeWhatsApp } from './entities/mensaje.entity.js';
import { MensajeWhatsAppDto } from './dto/mensaje.dto.js';
import { UpdateEstadoDto } from './dto/update-estado.dto.js';
import { defaultServiceResult, ServiceResult } from '../../shared/interfaces/service.result.js';
import { WhereCondition } from '../../shared/type/query-builder.types.js';
@Injectable()
export class MensajeService {
constructor(
@InjectRepository(MensajeWhatsApp)
private repo: Repository<MensajeWhatsApp>,
) {}
async findByPaquete(idPaquete: string): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
try {
const result = await this.repo.find({
where: { idPaquete },
order: { fechaEnvio: 'DESC' },
});
serviceResult.boolean = result.length > 0;
serviceResult.message = `${result.length} mensaje(s) encontrado(s).`;
serviceResult.number = result.length;
serviceResult.data = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async findPendientes(limit: number = 5): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
try {
const mensajes = await this.repo.find({
where: { estado: 'PENDIENTE' },
order: { fechaCreacion: 'ASC' },
take: limit,
});
if (mensajes.length === 0) {
serviceResult.boolean = false;
serviceResult.message = 'No hay mensajes pendientes.';
serviceResult.number = 0;
serviceResult.data = [];
return serviceResult;
}
const data = mensajes.map(m => ({
idMensajeWhatsApp: m.idMensajeWhatsApp,
idPaquete: m.idPaquete,
telefonoDestino: m.telefonoDestino,
tipo: m.tipo,
mensaje1: m.mensaje1,
mensaje2: m.mensaje2,
mensaje3: m.mensaje3,
urlQR: m.urlQR,
estado: m.estado,
intentos: m.intentos,
sessionId: m.sessionId,
fechaCreacion: m.fechaCreacion,
}));
serviceResult.boolean = true;
serviceResult.message = `${data.length} mensaje(s) pendiente(s).`;
serviceResult.number = data.length;
serviceResult.data = data;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async create(dto: MensajeWhatsAppDto): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
try {
const mensaje = this.repo.create({
...dto,
});
const result = await this.repo.save(mensaje);
serviceResult.boolean = true;
serviceResult.message = 'Mensaje registrado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async find(
whereConditions: WhereCondition[],
orderByAttribute: string,
orderByDirection: 'ASC' | 'DESC',
limit: number,
offset: number,
): Promise<ServiceResult> {
const serviceResult = defaultServiceResult();
try {
let query = this.repo.createQueryBuilder('mensaje');
whereConditions.forEach((condition, index) => {
const { attribute, value, operator = '=' } = condition;
const paramKey = `param${index}`;
const param = {
[paramKey]: operator === 'like' ? `%${value}%` : value,
};
const whereMethod = index === 0 ? 'where' : 'andWhere';
query = query[whereMethod](
`mensaje.${attribute} ${operator} :${paramKey}`,
param,
);
});
query = query.orderBy(`mensaje.${orderByAttribute}`, orderByDirection);
const totalRegistros = await query.getCount();
const skip = (offset - 1) * limit;
query = query.skip(skip).take(limit);
const result = await query.getMany();
const count = result.length;
serviceResult.boolean = count > 0;
serviceResult.message = `${count} Mensaje(s) encontrado(s).`;
serviceResult.number = totalRegistros;
serviceResult.data = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async updateEstado(id: string, dto: UpdateEstadoDto): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
try {
const mensaje = await this.repo.findOne({ where: { idMensajeWhatsApp: id } });
if (!mensaje) {
serviceResult.message = `Mensaje ${id} no encontrado.`;
return serviceResult;
}
mensaje.estado = dto.estado;
if (dto.error) {
mensaje.error = dto.error;
}
if (dto.fechaEnvio) {
mensaje.fechaEnvio = new Date(dto.fechaEnvio);
}
if (dto.estado === 'PENDIENTE') {
mensaje.intentos += 1;
}
const result = await this.repo.save(mensaje);
serviceResult.boolean = true;
serviceResult.message = 'Estado actualizado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
}

View file

@ -0,0 +1,170 @@
// Arrays de emojis por categoría — uno se elige al azar por mensaje
const EMOJI = {
SALUDO: ['👋', '🙌', '✨', '😊', '🎯'],
PAQUETE: ['📦', '📬', '📫', '🎁', '📩'],
CALENDARIO: ['📅', '📆', '🗓️', '⏳', '⏰'],
DINERO: ['💰', '💵', '💲', '🪙', '🤑'],
UBICACION: ['📍', '📌', '🗺️', '🏠', '🏢'],
HORARIO: ['🕐', '⏰', '🕰️', '⌛', '➿'],
AVISO: ['⚠️', '🔔', '💡', '❗', '📢'],
MAPS: ['🗺️', '📍', '🧭', '📌', '🌍'],
};
function pick(arr: string[]): string {
return arr[Math.floor(Math.random() * arr.length)];
}
function formatFecha(date: Date): string {
const d = date.getDate().toString().padStart(2, '0');
const m = (date.getMonth() + 1).toString().padStart(2, '0');
const y = date.getFullYear();
return `${d}/${m}/${y}`;
}
// ─── RECEPCION_PAQUETE (3 mensajes) ──────────────────────────────────
export interface TemplateRecibidoData {
nombreCliente: string;
nombreSucursal: string;
fechaLimite: Date;
costoBase: number;
diasIncluidos: number;
costoDiaAdicional: number;
}
// Mensaje 1: Principal (se envía como caption con imagen QR)
export function templateRecibido1(data: TemplateRecibidoData): string {
const e = {
saludo: pick(EMOJI.SALUDO),
paquete: pick(EMOJI.PAQUETE),
calendario: pick(EMOJI.CALENDARIO),
dinero: pick(EMOJI.DINERO),
};
const fechaHoy = new Date();
const fechaDesde = formatFecha(fechaHoy);
const fechaHasta = formatFecha(data.fechaLimite);
return `${e.paquete} *PAQUETE RECIBIDO*
¡Hola ${e.saludo} *${data.nombreCliente}*!
Tu *paquete* ha sido recibido en nuestra sucursal *"${data.nombreSucursal}"*, puedes pasar a recogerlo desde el día *${fechaDesde}* hasta el *${fechaHasta}* sin costo adicional.
Recuerda que el costo para recoger tu paquete es de ${e.dinero} *${data.costoBase} Bs.* y tienes *${data.diasIncluidos} días* para hacerlo. Pasados los *${data.diasIncluidos} días* se aplicará un adicional de ${e.dinero} *${data.costoDiaAdicional}* Bs. por día y por paquete.`;
}
// ======================================================================================================== //
// Mensaje 2: Ubicación
export interface TemplateRecibidoUbicacionData {
nombreSucursal: string;
descripcion: string;
direccion: string;
mapsUrl: string | null;
}
export function templateRecibido2(data: TemplateRecibidoUbicacionData): string {
const e = {
ubicacion: pick(EMOJI.UBICACION),
maps: pick(EMOJI.MAPS),
};
let mensaje = `*UBICACIÓN SUCURSAL "${data.nombreSucursal}"*\n`;
mensaje += `\n`;
mensaje += `*Encuéntranos en:* ${e.ubicacion} ${data.direccion}\n`;
if (data.descripcion) {
mensaje += `${data.descripcion}\n`;
}
if (data.mapsUrl) {
mensaje += `\n`;
mensaje += `*Ubicación en Google Maps:* ${e.maps} ${data.mapsUrl}`;
}
return mensaje;
}
// ======================================================================================================== //
// Mensaje 3: Horarios
export interface TemplateRecibidoHorarioData {
horariosAtencion: string;
}
export function templateRecibido3(data: TemplateRecibidoHorarioData): string {
const e = {
horario: pick(EMOJI.HORARIO),
};
return `${e.horario} *HORARIOS DE ATENCIÓN*
${data.horariosAtencion}
Excepto domingos y feriados.`;
}
// ─── PAQUETE_ENTREGADO (1 mensaje) ──────────────────────────────────
export interface TemplateEntregadoData {
nombreCliente: string;
whatsappCliente: string;
codigoCompleto: string;
nombreSucursal: string;
montoCobrar: number;
}
export function templateEntregado(data: TemplateEntregadoData): string {
const e = {
paquete: pick(EMOJI.PAQUETE),
aviso: pick(EMOJI.AVISO),
dinero: pick(EMOJI.DINERO),
saludo: pick(EMOJI.SALUDO),
};
return `${e.paquete} PAQUETE ENTREGADO
${e.saludo} Tu paquete *${data.codigoCompleto}* ha sido recogido por ${data.whatsappCliente} en "${data.nombreSucursal}".
${e.aviso} !Gracias por confiar en nosotros!`;
}
// ─── NOTIFICACION_EMPRENDEDOR (lote de paquetes recibidos) ──────────────────────────────────
export interface PaqueteLoteInfo {
codigoCompleto: string;
nombreCliente: string;
whatsappCliente: string;
}
export interface TemplateRecibidoEmprendedorData {
nombreEmprendimiento: string;
nombreSucursal: string;
paquetes: PaqueteLoteInfo[];
}
export function templateRecibidoEmprendedor(data: TemplateRecibidoEmprendedorData): string {
const e = {
paquete: pick(EMOJI.PAQUETE),
ubicacion: pick(EMOJI.UBICACION),
calendario: pick(EMOJI.CALENDARIO),
saludo: pick(EMOJI.SALUDO),
};
const fecha = formatFecha(new Date());
const hora = new Date().toLocaleTimeString('es-BO', { hour: '2-digit', minute: '2-digit', hour12: false });
let lista = data.paquetes.map((p, i) =>
`${i + 1}. *${p.codigoCompleto}* → ${p.nombreCliente} (${p.whatsappCliente})`
).join('\n');
return `${e.paquete} *PAQUETES RECIBIDOS*
${e.saludo} *${data.nombreEmprendimiento}*,
Se han registrado *${data.paquetes.length} paquete(s)* a tu nombre:
${lista}
${e.ubicacion} Sucursal: *${data.nombreSucursal}*
${e.calendario} Fecha: *${fecha} ${hora}*`;
}

View file

@ -0,0 +1,9 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CambiarUbicacionDto {
@ApiProperty({ example: 'uuid-zona' })
@IsString()
@IsNotEmpty()
idZonaAlmacenamiento: string;
}

View file

@ -0,0 +1,75 @@
import { IsString, IsOptional, IsBoolean, IsDecimal, IsArray, ValidateNested, IsNotEmpty } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class PaqueteLoteItemDto {
@ApiProperty({ example: 'Juan Pérez' })
@IsString()
@IsNotEmpty()
nombreCliente: string;
@ApiProperty({ example: '591' })
@IsString()
@IsNotEmpty()
codigoPaisCliente: string;
@ApiProperty({ example: '70123456' })
@IsString()
@IsNotEmpty()
whatsappCliente: string;
@ApiPropertyOptional({ example: 'uuid-categoria' })
@IsString()
@IsOptional()
idCategoria?: string;
@ApiPropertyOptional({ example: 'uuid-zona' })
@IsString()
@IsOptional()
idZonaAlmacenamiento?: string;
@ApiPropertyOptional({ example: '/uploads/paquetes/abc.jpg' })
@IsString()
@IsOptional()
fotoUrl?: string;
@ApiPropertyOptional({ example: false })
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
paquetePagado?: boolean;
@ApiPropertyOptional({ example: false })
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
saldo?: boolean;
@ApiPropertyOptional({ example: 0 })
@IsDecimal()
@IsOptional()
saldoPendiente?: number;
}
export class CrearLoteDto {
@ApiProperty({ example: 'uuid-sucursal' })
@IsString()
@IsNotEmpty()
idSucursal: string;
@ApiPropertyOptional({ example: 'uuid-emprendimiento' })
@IsString()
@IsOptional()
idEmprendimiento?: string;
@ApiPropertyOptional({ example: 'uuid-usuario' })
@IsString()
@IsOptional()
idUsuarioRecepcion?: string;
@ApiProperty({ type: [PaqueteLoteItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PaqueteLoteItemDto)
paquetes: PaqueteLoteItemDto[];
}

View file

@ -0,0 +1,19 @@
import { IsOptional, IsDecimal, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class EntregarPaqueteDto {
@ApiPropertyOptional({ example: 11.00 })
@IsDecimal()
@IsOptional()
montoCobrarPaquete?: number;
@ApiPropertyOptional({ example: 11.00 })
@IsDecimal()
@IsOptional()
montoFinal?: number;
@ApiPropertyOptional({ example: 'uuid-usuario' })
@IsString()
@IsOptional()
idUsuarioEntrega?: string;
}

View file

@ -0,0 +1,72 @@
import { IsString, IsOptional, IsBoolean, IsDecimal, IsUUID, IsNotEmpty } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class PaqueteDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idPaquete: string;
@ApiProperty({ example: 'uuid-sucursal' })
@IsString()
idSucursal: string;
@ApiPropertyOptional({ example: 'uuid-categoria' })
@IsString()
@IsOptional()
idCategoria?: string;
@ApiPropertyOptional({ example: 'uuid-zona' })
@IsString()
@IsOptional()
idZonaAlmacenamiento?: string;
@ApiPropertyOptional({ example: 'uuid-emprendimiento' })
@IsString()
@IsOptional()
idEmprendimiento?: string;
// Datos del cliente (receptor)
@ApiProperty({ example: 'Juan Pérez' })
@IsString()
@IsNotEmpty()
nombreCliente: string;
@ApiProperty({ example: '51' })
@IsString()
@IsNotEmpty()
codigoPaisCliente: string;
@ApiProperty({ example: '70123456' })
@IsString()
@IsNotEmpty()
whatsappCliente: string;
@ApiPropertyOptional({ example: '/uploads/paquetes/abc.jpg' })
@IsString()
@IsOptional()
fotoUrl?: string;
@ApiPropertyOptional({ example: false })
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
paquetePagado?: boolean;
@ApiPropertyOptional({ example: false })
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
saldo?: boolean;
@ApiPropertyOptional({ example: 0 })
@IsDecimal()
@IsOptional()
saldoPendiente?: number;
@ApiPropertyOptional({ example: 'uuid-usuario' })
@IsString()
@IsOptional()
idUsuarioRecepcion?: string;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { PaqueteDto } from './paquete.dto.js';
export class UpdatePaqueteDto extends PartialType(PaqueteDto) { }

View file

@ -0,0 +1,108 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity()
export class Paquete {
@PrimaryGeneratedColumn('uuid')
idPaquete: string;
@Column()
idSucursal: string;
@Column({ nullable: true })
idLote: string;
@Column({ nullable: true })
idCategoria: string;
@Column({ nullable: true })
idZonaAlmacenamiento: string;
@Column({ nullable: true })
idEmprendimiento: string;
// Datos del cliente (receptor)
@Column()
nombreCliente: string;
@Column()
codigoPaisCliente: string;
@Column()
whatsappCliente: string;
// Campos de identificación
@Column()
prefijoSucursal: string;
@Column({ type: 'int' })
numeroCorrelativo: number;
@Column()
letraCorrelativo: string;
@Column()
color: string;
@Column()
codigoCompleto: string;
// Imágenes
@Column()
fotoUrl: string;
@Column({ unique: true })
tokenRetiro: string;
@Column()
urlQR: string;
// Estado
@Column({
type: 'enum',
enum: ['RECIBIDO', 'ALMACENADO', 'ENTREGADO', 'DEVUELTO'],
default: 'RECIBIDO',
})
estado: string;
// Recepción
@CreateDateColumn()
fechaRecepcion: Date;
@Column({ type: 'timestamp' })
fechaLimite: Date;
@Column()
idUsuarioRecepcion: string;
// Pago
@Column({ default: false })
paquetePagado: boolean;
// Cobro Paquetería
@Column({ type: 'int', default: 0 })
diasAdicionales: number;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
montoCalculadoPaquete: number;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
montoCobrarPaquete: number;
// Saldo del Paquete
@Column({ default: false })
saldo: boolean;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
saldoPendiente: number;
// Total
@Column({ type: 'decimal', precision: 10, scale: 2 })
montoFinal: number;
// Entrega
@Column({ type: 'timestamp', nullable: true })
fechaEntrega: Date;
@Column({ nullable: true })
idUsuarioEntrega: string;
}

View file

@ -0,0 +1,376 @@
import {
Controller,
Get,
Post,
Put,
Patch,
Delete,
Body,
Param,
Query,
UseInterceptors,
UploadedFile,
Req,
HttpStatus,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { PaqueteService } from './paquete.service.js';
import { PaqueteDto } from './dto/paquete.dto.js';
import { UpdatePaqueteDto } from './dto/update-paquete.dto.js';
import { EntregarPaqueteDto } from './dto/entregar-paquete.dto.js';
import { CambiarUbicacionDto } from './dto/cambiar-ubicacion.dto.js';
import { CrearLoteDto } from './dto/crear-lote.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routePaquete } from './paquete.router.js';
@Controller('paquete')
export class PaqueteController {
constructor(private readonly service: PaqueteService) {}
@Get()
async findAll(
@Query('page') page?: number,
@Query('limit') limit?: number,
@Query('estado') estado?: string,
@Query('idCategoria') idCategoria?: string,
@Query('buscar') buscar?: string,
@Query('fechaDesde') fechaDesde?: string,
@Query('fechaHasta') fechaHasta?: string,
@Query('porVencer') porVencer?: boolean,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.find.title,
routePaquete.find.route,
);
try {
const result = await this.service.findAll({
page,
limit,
estado,
idCategoria,
buscar,
fechaDesde,
fechaHasta,
porVencer,
});
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = result.total;
apiResult.data = result.data;
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Get('estadisticas/hoy')
async estadisticasHoy(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.find.title,
routePaquete.find.route,
);
try {
const data = await this.service.estadisticasHoy();
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Get('estadisticas/periodo')
async estadisticasPeriodo(
@Query('fechaDesde') fechaDesde: string,
@Query('fechaHasta') fechaHasta: string,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.find.title,
routePaquete.find.route,
);
try {
const data = await this.service.estadisticasPeriodo(fechaDesde, fechaHasta);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Get('limpiar/preview')
async previewLimpiar(
@Query('fechaDesde') fechaDesde: string,
@Query('fechaHasta') fechaHasta: string,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.find.title,
routePaquete.find.route,
);
try {
const data = await this.service.previewLimpiar(fechaDesde, fechaHasta);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Delete('limpiar')
async limpiar(
@Query('fechaDesde') fechaDesde: string,
@Query('fechaHasta') fechaHasta: string,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.remove.title,
routePaquete.remove.route,
);
try {
const data = await this.service.limpiar(fechaDesde, fechaHasta);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Limpieza completada';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Get('token/:tokenRetiro')
async findByToken(@Param('tokenRetiro') tokenRetiro: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.one.title,
routePaquete.one.route,
);
try {
const data = await this.service.findByToken(tokenRetiro);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Get(':idPaquete')
async findOne(@Param('idPaquete') idPaquete: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.one.title,
routePaquete.one.route,
);
try {
const result = await this.service.findOne(idPaquete);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Post()
@UseInterceptors(FileInterceptor('file'))
async create(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: PaqueteDto,
@Req() req: any,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.create.title,
routePaquete.create.route,
);
try {
const data = await this.service.create(dto, file, dto.idUsuarioRecepcion || req?.user?.idUsuario || '');
apiResult.status = 'correct';
apiResult.code = HttpStatus.CREATED;
apiResult.message = 'Paquete registrado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Post('lote')
async createLote(
@Body() dto: CrearLoteDto,
@Req() req: any,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.createLote.title,
routePaquete.createLote.route,
);
try {
const idUsuario = dto.idUsuarioRecepcion || req?.user?.idUsuario || '';
const data = await this.service.createLote(dto, idUsuario);
apiResult.status = 'correct';
apiResult.code = HttpStatus.CREATED;
apiResult.message = `${dto.paquetes.length} paquete(s) registrado(s) correctamente`;
apiResult.boolean = true;
apiResult.rows = data.paquetes.length;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Patch(':idPaquete')
async update(
@Param('idPaquete') idPaquete: string,
@Body() dto: UpdatePaqueteDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.update.title,
routePaquete.update.route,
);
try {
const data = await this.service.update(idPaquete, dto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Paquete actualizado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Put(':idPaquete/entregar')
async entregar(
@Param('idPaquete') idPaquete: string,
@Body() dto: EntregarPaqueteDto,
@Req() req: any,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.update.title,
routePaquete.update.route,
);
try {
const idUsuario = req?.user?.idUsuario || dto.idUsuarioEntrega || '';
const data = await this.service.entregar(idPaquete, dto, idUsuario);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Paquete entregado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Put(':idPaquete/devolver')
async devolver(@Param('idPaquete') idPaquete: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.update.title,
routePaquete.update.route,
);
try {
const data = await this.service.devolver(idPaquete);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Paquete devuelto correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
@Put(':idPaquete/ubicacion')
async cambiarUbicacion(
@Param('idPaquete') idPaquete: string,
@Body() dto: CambiarUbicacionDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routePaquete.update.title,
routePaquete.update.route,
);
try {
const data = await this.service.cambiarUbicacion(idPaquete, dto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Ubicación actualizada correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || 500;
apiResult.message = error.message || 'Error del servidor';
}
return apiResult;
}
}

View file

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaqueteController } from './paquete.controller.js';
import { PaqueteService } from './paquete.service.js';
import { Paquete } from './entities/paquete.entity.js';
import { Sucursal } from '../sucursal/entities/sucursal.entity.js';
import { Feriado } from '../feriado/entities/feriado.entity.js';
import { Categoria } from '../categoria/entities/categoria.entity.js';
import { Emprendimiento } from '../emprendimiento/entities/emprendimiento.entity.js';
import { QrModule } from '../../shared/services/qr/qr.module.js';
import { MensajeModule } from '../mensaje/mensaje.module.js';
@Module({
imports: [
TypeOrmModule.forFeature([Paquete, Sucursal, Feriado, Categoria, Emprendimiento]),
QrModule,
MensajeModule,
],
controllers: [PaqueteController],
providers: [PaqueteService],
exports: [PaqueteService],
})
export class PaqueteModule {}

View file

@ -0,0 +1,30 @@
export const routePaquete = {
multiple: {
route: '[POST].../v1/paquete/multiple',
title: 'Create Multiple Paquete',
},
create: {
route: '[POST].../v1/paquete',
title: 'Create Paquete',
},
createLote: {
route: '[POST].../v1/paquete/lote',
title: 'Create Lote Paquete',
},
one: {
route: '[GET].../v1/paquete/:idPaquete',
title: 'Find One Paquete',
},
find: {
route: '[POST].../v1/paquete/find',
title: 'Find Paquete WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/paquete/:idPaquete',
title: 'Update Paquete',
},
remove: {
route: '[DELETE].../v1/paquete/:idPaquete',
title: 'Remove Paquete',
},
};

View file

@ -0,0 +1,739 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { Paquete } from './entities/paquete.entity.js';
import { Sucursal } from '../sucursal/entities/sucursal.entity.js';
import { Feriado } from '../feriado/entities/feriado.entity.js';
import { Categoria } from '../categoria/entities/categoria.entity.js';
import { Emprendimiento } from '../emprendimiento/entities/emprendimiento.entity.js';
import { PaqueteDto } from './dto/paquete.dto.js';
import { UpdatePaqueteDto } from './dto/update-paquete.dto.js';
import { EntregarPaqueteDto } from './dto/entregar-paquete.dto.js';
import { CambiarUbicacionDto } from './dto/cambiar-ubicacion.dto.js';
import { CrearLoteDto } from './dto/crear-lote.dto.js';
import { QrRenderer } from '../../shared/services/qr/qr.renderer.js';
import { MensajeService } from '../mensaje/mensaje.service.js';
import { templateRecibido1, templateRecibido2, templateRecibido3, templateEntregado, templateRecibidoEmprendedor } from '../mensaje/mensaje.templates.js';
import {
calcularDiasAdicionales,
calcularFechaLimite,
obtenerFeriadosDelRango,
} from '../../shared/utils/fecha.util.js';
@Injectable()
export class PaqueteService {
private uploadsPath = path.join(process.cwd(), 'uploads');
constructor(
@InjectRepository(Paquete)
private paqueteRepo: Repository<Paquete>,
@InjectRepository(Sucursal)
private sucursalRepo: Repository<Sucursal>,
@InjectRepository(Feriado)
private feriadoRepo: Repository<Feriado>,
@InjectRepository(Categoria)
private categoriaRepo: Repository<Categoria>,
@InjectRepository(Emprendimiento)
private emprendimientoRepo: Repository<Emprendimiento>,
private readonly qrRenderer: QrRenderer,
private readonly mensajeService: MensajeService,
) {}
async findAll(filters: {
page?: number;
limit?: number;
estado?: string;
idCategoria?: string;
buscar?: string;
fechaDesde?: string;
fechaHasta?: string;
porVencer?: boolean;
}) {
const { page = 1, limit = 20, ...rest } = filters;
const skip = (page - 1) * limit;
const qb = this.paqueteRepo.createQueryBuilder('p');
if (rest.estado) {
qb.andWhere('p.estado = :estado', { estado: rest.estado });
}
if (rest.idCategoria) {
qb.andWhere('p.idCategoria = :idCategoria', { idCategoria: rest.idCategoria });
}
if (rest.buscar) {
qb.andWhere('p.codigoCompleto ILIKE :buscar', { buscar: `%${rest.buscar}%` });
}
if (rest.fechaDesde) {
qb.andWhere('p.fechaRecepcion >= :fechaDesde', { fechaDesde: rest.fechaDesde });
}
if (rest.fechaHasta) {
qb.andWhere('p.fechaRecepcion <= :fechaHasta', { fechaHasta: rest.fechaHasta });
}
if (rest.porVencer) {
const manana = new Date();
manana.setDate(manana.getDate() + 2);
qb.andWhere('p.fechaLimite <= :manana AND p.estado != :entregado', {
manana: manana.toISOString(),
entregado: 'ENTREGADO',
});
}
const [data, total] = await qb
.skip(skip)
.take(limit)
.orderBy('p.fechaRecepcion', 'DESC')
.getManyAndCount();
return { data, total, page, limit };
}
async findOne(id: string) {
const paquete = await this.paqueteRepo.findOne({ where: { idPaquete: id } });
if (!paquete) throw new NotFoundException('Paquete no encontrado');
// Asegurar que las fechas se retornen en formato ISO con timezone
paquete.fechaRecepcion = new Date(paquete.fechaRecepcion);
paquete.fechaLimite = new Date(paquete.fechaLimite);
if (paquete.fechaEntrega) {
paquete.fechaEntrega = new Date(paquete.fechaEntrega);
}
return paquete;
}
async findByToken(tokenRetiro: string) {
const paquete = await this.paqueteRepo.findOne({ where: { tokenRetiro } });
if (!paquete) throw new NotFoundException('No se encontró un paquete con ese token');
if (paquete.estado === 'RECIBIDO' || paquete.estado === 'ALMACENADO') {
const sucursal = await this.sucursalRepo.findOne({ where: { idSucursal: paquete.idSucursal } });
if (!sucursal) throw new NotFoundException('Sucursal no encontrada');
const hoy = new Date();
const feriados = await obtenerFeriadosDelRango(this.feriadoRepo, paquete.fechaRecepcion, hoy);
const diasAdicionales = calcularDiasAdicionales(
paquete.fechaRecepcion,
hoy,
sucursal.diasIncluidos,
feriados,
);
let montoCalculado: number;
if (paquete.paquetePagado) {
montoCalculado = Number(sucursal.costoDiaAdicional) * diasAdicionales;
} else {
montoCalculado = Number(sucursal.costoBase) + Number(sucursal.costoDiaAdicional) * diasAdicionales;
}
paquete.diasAdicionales = diasAdicionales;
paquete.montoCalculadoPaquete = montoCalculado;
paquete.montoCobrarPaquete = montoCalculado;
paquete.montoFinal = montoCalculado + Number(paquete.saldoPendiente);
}
// Asegurar que las fechas se retornen en formato ISO con timezone
paquete.fechaRecepcion = new Date(paquete.fechaRecepcion);
paquete.fechaLimite = new Date(paquete.fechaLimite);
return paquete;
}
async create(
dto: PaqueteDto,
file: Express.Multer.File | undefined,
idUsuarioRecepcion: string,
) {
// 1. Validar sucursal
const sucursal = await this.sucursalRepo.findOne({ where: { idSucursal: dto.idSucursal } });
if (!sucursal) throw new NotFoundException('Sucursal no encontrada');
// 2. Validar categoría
if (dto.idCategoria) {
const categoria = await this.categoriaRepo.findOne({ where: { idCategoria: dto.idCategoria } });
if (!categoria) throw new NotFoundException('Categoría no encontrada');
}
// 3. Validar emprendimiento
if (dto.idEmprendimiento) {
const emprendimiento = await this.emprendimientoRepo.findOne({ where: { idEmprendimiento: dto.idEmprendimiento } });
if (!emprendimiento) throw new NotFoundException('Emprendimiento no encontrado');
}
// 4. Generar token y QR
const tokenRetiro = uuidv4();
const urlQR = await this.qrRenderer.generar(tokenRetiro, sucursal.nombre);
// 6. Manejar imagen
let fotoUrl: string;
if (file) {
// Caso A: Imagen enviada en la petición
const compressBuffer = await sharp(file.buffer)
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
const fotoFilename = `${uuidv4()}.jpg`;
const fotoPath = path.join(this.uploadsPath, 'paquetes', fotoFilename);
fs.mkdirSync(path.dirname(fotoPath), { recursive: true });
fs.writeFileSync(fotoPath, compressBuffer);
fotoUrl = `/uploads/paquetes/${fotoFilename}`;
} else {
// Caso B: Imagen de flujo previo (OCR/upload)
if (!dto.fotoUrl) {
throw new NotFoundException('No se proporcionó imagen (file o fotoUrl)');
}
fotoUrl = dto.fotoUrl;
}
// 7. Calcular código correlativo
const ultimoPaquete = await this.paqueteRepo.findOne({
where: { idSucursal: sucursal.idSucursal },
order: { numeroCorrelativo: 'DESC', letraCorrelativo: 'DESC' },
});
let numeroCorrelativo = 1;
let letraCorrelativo = sucursal.letraActual;
if (ultimoPaquete) {
if (ultimoPaquete.numeroCorrelativo >= sucursal.cantidadCodigos) {
letraCorrelativo = String.fromCharCode(ultimoPaquete.letraCorrelativo.charCodeAt(0) + 1);
numeroCorrelativo = 1;
} else {
numeroCorrelativo = ultimoPaquete.numeroCorrelativo + 1;
letraCorrelativo = ultimoPaquete.letraCorrelativo;
}
}
const prefijo = sucursal.usarPrefijoSucursal ? sucursal.prefijo : '';
const codigoCompleto = sucursal.formatoCodigo
.replace('{prefijo}', prefijo)
.replace('{numero}', String(numeroCorrelativo).padStart(4, '0'))
.replace('{letra}', letraCorrelativo);
// 8. Calcular fecha límite
const hoy = new Date();
const feriados = await obtenerFeriadosDelRango(this.feriadoRepo, hoy, new Date(hoy.getFullYear(), 11, 31));
const fechaLimite = calcularFechaLimite(hoy, sucursal.diasIncluidos, feriados);
// 8. Crear paquete
const paquete = this.paqueteRepo.create({
idSucursal: sucursal.idSucursal,
idCategoria: dto.idCategoria,
idZonaAlmacenamiento: dto.idZonaAlmacenamiento,
idEmprendimiento: dto.idEmprendimiento,
nombreCliente: dto.nombreCliente,
codigoPaisCliente: dto.codigoPaisCliente,
whatsappCliente: dto.whatsappCliente,
prefijoSucursal: sucursal.prefijo,
numeroCorrelativo,
letraCorrelativo,
color: sucursal.colorActual,
codigoCompleto,
fotoUrl,
tokenRetiro,
urlQR,
fechaLimite,
idUsuarioRecepcion,
paquetePagado: dto.paquetePagado || false,
saldo: dto.saldo || false,
saldoPendiente: dto.saldoPendiente || 0,
montoCalculadoPaquete: 0,
montoCobrarPaquete: 0,
montoFinal: 0,
});
// 10. Actualizar sucursal
if (numeroCorrelativo === 1 && letraCorrelativo !== sucursal.letraActual) {
sucursal.letraActual = letraCorrelativo;
}
sucursal.numeroActual = numeroCorrelativo;
await this.sucursalRepo.save(sucursal);
const paqueteGuardado = await this.paqueteRepo.save(paquete);
// 10. Crear mensaje WhatsApp (3 mensajes)
const textoMensaje1 = templateRecibido1({
nombreCliente: dto.nombreCliente,
nombreSucursal: sucursal.nombre,
fechaLimite: fechaLimite,
costoBase: Number(sucursal.costoBase),
diasIncluidos: sucursal.diasIncluidos,
costoDiaAdicional: Number(sucursal.costoDiaAdicional),
});
const textoMensaje2 = templateRecibido2({
nombreSucursal: sucursal.nombre,
descripcion: sucursal.descripcion || '',
direccion: sucursal.direccion,
mapsUrl: sucursal.mapsUrl,
});
const textoMensaje3 = templateRecibido3({
horariosAtencion: sucursal.horariosAtencion,
});
const mensaje = await this.mensajeService.create({
idPaquete: paqueteGuardado.idPaquete,
telefonoDestino: `${dto.codigoPaisCliente}${dto.whatsappCliente}`,
tipo: 'RECEPCION_PAQUETE',
mensaje1: textoMensaje1,
mensaje2: textoMensaje2,
mensaje3: textoMensaje3,
urlQR: paqueteGuardado.urlQR,
sessionId: sucursal.sessionId,
});
return { paquete: paqueteGuardado, mensaje };
}
async createLote(dto: CrearLoteDto, idUsuarioRecepcion: string) {
// 1. Validar sucursal
const sucursal = await this.sucursalRepo.findOne({ where: { idSucursal: dto.idSucursal } });
if (!sucursal) throw new NotFoundException('Sucursal no encontrada');
// 2. Validar emprendimiento (opcional)
let emprendimiento = null;
if (dto.idEmprendimiento) {
emprendimiento = await this.emprendimientoRepo.findOne({ where: { idEmprendimiento: dto.idEmprendimiento } });
if (!emprendimiento) throw new NotFoundException('Emprendimiento no encontrado');
}
// 3. Obtener feriados del año
const hoy = new Date();
const feriados = await obtenerFeriadosDelRango(this.feriadoRepo, hoy, new Date(hoy.getFullYear(), 11, 31));
// 4. Generar idLote único para este batch
const idLote = uuidv4();
const paquetesCreados: any[] = [];
const infoPaquetesEmprendedor: { codigoCompleto: string; nombreCliente: string; whatsappCliente: string }[] = [];
// 4. Procesar cada paquete del lote
for (const item of dto.paquetes) {
// Validar categoría si se proporciona
if (item.idCategoria) {
const categoria = await this.categoriaRepo.findOne({ where: { idCategoria: item.idCategoria } });
if (!categoria) throw new NotFoundException(`Categoría ${item.idCategoria} no encontrada`);
}
// Generar token y QR
const tokenRetiro = uuidv4();
const urlQR = await this.qrRenderer.generar(tokenRetiro, sucursal.nombre);
// Calcular código correlativo
const ultimoPaquete = await this.paqueteRepo.findOne({
where: { idSucursal: sucursal.idSucursal },
order: { numeroCorrelativo: 'DESC', letraCorrelativo: 'DESC' },
});
let numeroCorrelativo = 1;
let letraCorrelativo = sucursal.letraActual;
if (ultimoPaquete) {
if (ultimoPaquete.numeroCorrelativo >= sucursal.cantidadCodigos) {
letraCorrelativo = String.fromCharCode(ultimoPaquete.letraCorrelativo.charCodeAt(0) + 1);
numeroCorrelativo = 1;
} else {
numeroCorrelativo = ultimoPaquete.numeroCorrelativo + 1;
letraCorrelativo = ultimoPaquete.letraCorrelativo;
}
}
const prefijo = sucursal.usarPrefijoSucursal ? sucursal.prefijo : '';
const codigoCompleto = sucursal.formatoCodigo
.replace('{prefijo}', prefijo)
.replace('{numero}', String(numeroCorrelativo).padStart(4, '0'))
.replace('{letra}', letraCorrelativo);
// Calcular fecha límite
const fechaLimite = calcularFechaLimite(hoy, sucursal.diasIncluidos, feriados);
// Crear paquete
const paquete = this.paqueteRepo.create({
idSucursal: sucursal.idSucursal,
idLote,
idCategoria: item.idCategoria,
idZonaAlmacenamiento: item.idZonaAlmacenamiento,
idEmprendimiento: dto.idEmprendimiento,
nombreCliente: item.nombreCliente,
codigoPaisCliente: item.codigoPaisCliente,
whatsappCliente: item.whatsappCliente,
prefijoSucursal: sucursal.prefijo,
numeroCorrelativo,
letraCorrelativo,
color: sucursal.colorActual,
codigoCompleto,
fotoUrl: item.fotoUrl || '',
tokenRetiro,
urlQR,
fechaLimite,
idUsuarioRecepcion,
paquetePagado: item.paquetePagado || false,
saldo: item.saldo || false,
saldoPendiente: item.saldoPendiente || 0,
montoCalculadoPaquete: 0,
montoCobrarPaquete: 0,
montoFinal: 0,
});
// Actualizar sucursal
if (numeroCorrelativo === 1 && letraCorrelativo !== sucursal.letraActual) {
sucursal.letraActual = letraCorrelativo;
}
sucursal.numeroActual = numeroCorrelativo;
await this.sucursalRepo.save(sucursal);
const paqueteGuardado = await this.paqueteRepo.save(paquete);
// Crear mensaje WhatsApp al cliente (3 mensajes)
const textoMensaje1 = templateRecibido1({
nombreCliente: item.nombreCliente,
nombreSucursal: sucursal.nombre,
fechaLimite: fechaLimite,
costoBase: Number(sucursal.costoBase),
diasIncluidos: sucursal.diasIncluidos,
costoDiaAdicional: Number(sucursal.costoDiaAdicional),
});
const textoMensaje2 = templateRecibido2({
nombreSucursal: sucursal.nombre,
descripcion: sucursal.descripcion || '',
direccion: sucursal.direccion,
mapsUrl: sucursal.mapsUrl,
});
const textoMensaje3 = templateRecibido3({
horariosAtencion: sucursal.horariosAtencion,
});
await this.mensajeService.create({
idPaquete: paqueteGuardado.idPaquete,
telefonoDestino: `${item.codigoPaisCliente}${item.whatsappCliente}`,
tipo: 'RECEPCION_PAQUETE',
mensaje1: textoMensaje1,
mensaje2: textoMensaje2,
mensaje3: textoMensaje3,
urlQR: paqueteGuardado.urlQR,
sessionId: sucursal.sessionId,
});
paquetesCreados.push(paqueteGuardado);
infoPaquetesEmprendedor.push({
codigoCompleto,
nombreCliente: item.nombreCliente,
whatsappCliente: `${item.codigoPaisCliente}${item.whatsappCliente}`,
});
}
// 5. Crear mensaje consolidado al emprendedor (si existe)
let mensajeEmprendedor = null;
if (emprendimiento) {
const textoEmprendedor = templateRecibidoEmprendedor({
nombreEmprendimiento: emprendimiento.emprendimiento,
nombreSucursal: sucursal.nombre,
paquetes: infoPaquetesEmprendedor,
});
mensajeEmprendedor = await this.mensajeService.create({
idPaquete: paquetesCreados[0].idPaquete,
telefonoDestino: `${emprendimiento.codigoPais}${emprendimiento.whatsapp}`,
tipo: 'NOTIFICACION_EMPRENDEDOR',
mensaje1: textoEmprendedor,
sessionId: sucursal.sessionId,
});
}
return { idLote, paquetes: paquetesCreados, mensajeEmprendedor };
}
async update(id: string, dto: UpdatePaqueteDto) {
const paquete = await this.paqueteRepo.findOne({ where: { idPaquete: id } });
if (!paquete) throw new NotFoundException('Paquete no encontrado');
if (paquete.estado === 'ENTREGADO') {
throw new ConflictException('El paquete ya fue entregado');
}
Object.assign(paquete, dto);
return this.paqueteRepo.save(paquete);
}
async entregar(id: string, dto: EntregarPaqueteDto, idUsuarioEntrega: string) {
const paquete = await this.paqueteRepo.findOne({ where: { idPaquete: id } });
if (!paquete) throw new NotFoundException('Paquete no encontrado');
if (paquete.estado !== 'RECIBIDO' && paquete.estado !== 'ALMACENADO') {
throw new ConflictException('El paquete no tiene estado válido para entrega');
}
const sucursal = await this.sucursalRepo.findOne({ where: { idSucursal: paquete.idSucursal } });
if (!sucursal) throw new NotFoundException('Sucursal no encontrada');
const hoy = new Date();
const feriados = await obtenerFeriadosDelRango(this.feriadoRepo, paquete.fechaRecepcion, hoy);
const diasAdicionales = calcularDiasAdicionales(
paquete.fechaRecepcion,
hoy,
sucursal.diasIncluidos,
feriados,
);
let montoCalculado: number;
if (paquete.paquetePagado) {
montoCalculado = Number(sucursal.costoDiaAdicional) * diasAdicionales;
} else {
montoCalculado = Number(sucursal.costoBase) + Number(sucursal.costoDiaAdicional) * diasAdicionales;
}
paquete.estado = 'ENTREGADO';
paquete.fechaEntrega = hoy;
paquete.idUsuarioEntrega = idUsuarioEntrega;
paquete.diasAdicionales = diasAdicionales;
paquete.montoCalculadoPaquete = montoCalculado;
paquete.montoCobrarPaquete = dto.montoCobrarPaquete ?? montoCalculado;
paquete.montoFinal = (dto.montoFinal ?? paquete.montoCobrarPaquete) + Number(paquete.saldoPendiente);
const paqueteGuardado = await this.paqueteRepo.save(paquete);
// Generar mensaje PAQUETE_ENTREGADO → se envía al emprendedor
let mensaje = null;
if (paquete.idEmprendimiento) {
const emprendimiento = await this.emprendimientoRepo.findOne({
where: { idEmprendimiento: paquete.idEmprendimiento },
});
if (emprendimiento) {
const textoMensaje = templateEntregado({
nombreCliente: paquete.nombreCliente,
whatsappCliente: emprendimiento.whatsapp,
codigoCompleto: paquete.codigoCompleto,
nombreSucursal: sucursal.nombre,
montoCobrar: Number(paquete.montoCobrarPaquete),
});
const telefonoDestino = `${emprendimiento.codigoPais}${emprendimiento.whatsapp}`;
mensaje = await this.mensajeService.create({
idPaquete: paqueteGuardado.idPaquete,
telefonoDestino,
tipo: 'PAQUETE_ENTREGADO',
mensaje1: textoMensaje,
sessionId: sucursal.sessionId,
});
}
}
return { paquete: paqueteGuardado, mensaje };
}
async devolver(id: string) {
const paquete = await this.paqueteRepo.findOne({ where: { idPaquete: id } });
if (!paquete) throw new NotFoundException('Paquete no encontrado');
if (paquete.estado === 'ENTREGADO') {
throw new ConflictException('El paquete ya fue entregado');
}
if (paquete.estado === 'DEVUELTO') {
throw new ConflictException('El paquete ya fue devuelto');
}
paquete.estado = 'DEVUELTO';
return this.paqueteRepo.save(paquete);
}
async cambiarUbicacion(id: string, dto: CambiarUbicacionDto) {
const paquete = await this.paqueteRepo.findOne({ where: { idPaquete: id } });
if (!paquete) throw new NotFoundException('Paquete no encontrado');
if (paquete.estado === 'ENTREGADO' || paquete.estado === 'DEVUELTO') {
throw new ConflictException('El paquete fue entregado o devuelto');
}
paquete.idZonaAlmacenamiento = dto.idZonaAlmacenamiento;
return this.paqueteRepo.save(paquete);
}
async estadisticasHoy() {
const hoy = new Date();
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate());
const recibidos = await this.paqueteRepo.count({
where: { estado: 'RECIBIDO' },
});
const entregados = await this.paqueteRepo.count({
where: { estado: 'ENTREGADO' },
});
const devueltos = await this.paqueteRepo.count({
where: { estado: 'DEVUELTO' },
});
const porVencer = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.fechaLimite <= :manana AND p.estado != :entregado', {
manana: new Date(hoy.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(),
entregado: 'ENTREGADO',
})
.getCount();
const vencidos = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.fechaLimite < :hoy AND p.estado != :entregado', {
hoy: inicioHoy.toISOString(),
entregado: 'ENTREGADO',
})
.getCount();
const ingresosResult = await this.paqueteRepo
.createQueryBuilder('p')
.select('SUM(p.montoCobrarPaquete)', 'total')
.where('p.estado = :entregado', { entregado: 'ENTREGADO' })
.getRawOne();
return {
fecha: hoy.toISOString().split('T')[0],
recibidos,
entregados,
devueltos,
porVencer,
vencidos,
ingresosDelDia: Number(ingresosResult?.total) || 0,
};
}
async estadisticasPeriodo(fechaDesde: string, fechaHasta: string) {
const recibidos = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.fechaRecepcion >= :desde AND p.fechaRecepcion <= :hasta', {
desde: fechaDesde,
hasta: fechaHasta,
})
.getCount();
const entregados = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.fechaEntrega >= :desde AND p.fechaEntrega <= :hasta', {
desde: fechaDesde,
hasta: fechaHasta,
})
.getCount();
const devueltos = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.estado = :devuelto AND p.fechaRecepcion >= :desde AND p.fechaRecepcion <= :hasta', {
devuelto: 'DEVUELTO',
desde: fechaDesde,
hasta: fechaHasta,
})
.getCount();
const ingresosResult = await this.paqueteRepo
.createQueryBuilder('p')
.select('SUM(p.montoCobrarPaquete)', 'total')
.where('p.estado = :entregado AND p.fechaEntrega >= :desde AND p.fechaEntrega <= :hasta', {
entregado: 'ENTREGADO',
desde: fechaDesde,
hasta: fechaHasta,
})
.getRawOne();
const pendientes = await this.paqueteRepo.count({
where: { estado: 'RECIBIDO' },
});
return {
fechaDesde,
fechaHasta,
totalRecibidos: recibidos,
totalEntregados: entregados,
totalDevueltos: devueltos,
ingresosTotales: Number(ingresosResult?.total) || 0,
paquetesPendientes: pendientes,
};
}
async previewLimpiar(fechaDesde: string, fechaHasta: string) {
const paquetes = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.estado = :entregado AND p.fechaEntrega >= :desde AND p.fechaEntrega <= :hasta', {
entregado: 'ENTREGADO',
desde: fechaDesde,
hasta: fechaHasta,
})
.getMany();
let totalArchivos = 0;
let totalBytes = 0;
for (const paquete of paquetes) {
const fotoPath = path.join(process.cwd(), paquete.fotoUrl);
const qrPath = path.join(process.cwd(), paquete.urlQR);
if (fs.existsSync(fotoPath)) {
totalArchivos++;
totalBytes += fs.statSync(fotoPath).size;
}
if (fs.existsSync(qrPath)) {
totalArchivos++;
totalBytes += fs.statSync(qrPath).size;
}
}
return {
paquetesEncontrados: paquetes.length,
archivosAEliminar: totalArchivos,
espacioLiberado: `${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
};
}
async limpiar(fechaDesde: string, fechaHasta: string) {
const paquetes = await this.paqueteRepo
.createQueryBuilder('p')
.where('p.estado = :entregado AND p.fechaEntrega >= :desde AND p.fechaEntrega <= :hasta', {
entregado: 'ENTREGADO',
desde: fechaDesde,
hasta: fechaHasta,
})
.getMany();
let archivosEliminados = 0;
for (const paquete of paquetes) {
const fotoPath = path.join(process.cwd(), paquete.fotoUrl);
if (fs.existsSync(fotoPath)) {
fs.unlinkSync(fotoPath);
archivosEliminados++;
}
const qrPath = path.join(process.cwd(), paquete.urlQR);
if (fs.existsSync(qrPath)) {
fs.unlinkSync(qrPath);
archivosEliminados++;
}
}
const result = await this.paqueteRepo
.createQueryBuilder()
.delete()
.from(Paquete)
.where('estado = :entregado AND fechaEntrega >= :desde AND fechaEntrega <= :hasta', {
entregado: 'ENTREGADO',
desde: fechaDesde,
hasta: fechaHasta,
})
.execute();
return {
paquetesEliminados: result.affected || 0,
archivosEliminados,
};
}
}

View file

@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, IsOptional, IsBoolean, IsUUID } from 'class-validator';
export class SucursalDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idSucursal: string;
@IsNotEmpty()
@ApiProperty({ example: 'Sucursal Central' })
nombre: string;
@IsNotEmpty()
@ApiProperty({ example: 'Av. 123 Edificio 456' })
direccion: string;
@IsOptional()
@ApiProperty({ example: 'Subiendo las gradas' })
descripcion?: string;
@IsNotEmpty()
@ApiProperty({ example: 'Lunes a Viernes de 08:00 a 19:00' })
horariosAtencion: string;
@IsOptional()
@ApiProperty({ example: 'https://maps.app.goo.gl/...' })
mapsUrl?: string;
@IsNotEmpty()
@ApiProperty({ example: 'S1' })
prefijo: string;
@IsOptional()
@ApiProperty({ example: '#4338CA' })
colorSucursal?: string;
@IsNumber()
@ApiProperty({ example: 1000 })
cantidadCodigos: number;
@IsOptional()
@ApiProperty({ example: '{prefijo}-{numero}-{letra}' })
formatoCodigo?: string;
@IsOptional()
@ApiProperty({ example: true })
usarPrefijoSucursal?: boolean;
@IsOptional()
@ApiProperty({ example: true })
usarPrefijoZonaAlmacenamiento?: boolean;
@IsOptional()
@ApiProperty({ example: false })
rotarColorAlFinalizarZ?: boolean;
@IsOptional()
@ApiProperty({ example: true })
whatsappActivo?: boolean;
@IsOptional()
@ApiProperty({ example: 'session-abc-123' })
sessionId?: string;
// Campos heredados de Empresa (editables por sucursal)
@IsOptional()
@IsNumber()
@ApiProperty({ example: 5 })
diasIncluidos?: number;
@IsOptional()
@IsNumber()
@ApiProperty({ example: 5.00 })
costoBase?: number;
@IsOptional()
@IsNumber()
@ApiProperty({ example: 2.00 })
costoDiaAdicional?: number;
@IsOptional()
@IsNumber()
@ApiProperty({ example: 15 })
diasParaAlmacen?: number;
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { SucursalDto } from './sucursal.dto.js';
export class UpdateSucursalDto extends PartialType(SucursalDto) { }

View file

@ -0,0 +1,74 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Sucursal {
@PrimaryGeneratedColumn('uuid')
idSucursal: string;
@Column()
nombre: string;
@Column()
direccion: string;
@Column({ nullable: true })
descripcion: string;
@Column()
horariosAtencion: string;
@Column({ nullable: true })
mapsUrl: string;
@Column()
colorSucursal: string;
@Column()
prefijo: string;
// Configuración heredada de Empresa
@Column({ type: 'int' })
diasIncluidos: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
costoBase: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
costoDiaAdicional: number;
@Column({ type: 'int' })
diasParaAlmacen: number;
// Gestión del ciclo visual (Letras y Colores)
@Column({ default: 'A' })
letraActual: string;
@Column({ type: 'int', default: 1 })
numeroActual: number;
@Column({ type: 'int', default: 1000 })
cantidadCodigos: number;
@Column()
colorActual: string;
// Personalización del código
@Column({ default: false })
rotarColorAlFinalizarZ: boolean;
@Column({ default: true })
usarPrefijoSucursal: boolean;
@Column({ default: true })
usarPrefijoZonaAlmacenamiento: boolean;
@Column({ default: '{prefijo}-{numero}-{letra}' })
formatoCodigo: string;
// WhatsApp
@Column({ nullable: true })
sessionId: string;
@Column({ default: true })
whatsappActivo: boolean;
}

View file

@ -0,0 +1,176 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, HttpStatus } from '@nestjs/common';
import { SucursalService } from './sucursal.service.js';
import { SucursalDto } from './dto/sucursal.dto.js';
import { UpdateSucursalDto } from './dto/update-sucursal.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { FindDto } from '../../shared/class/find.dto.js';
import { routeSucursal } from './sucursal.router.js';
@Controller('sucursal')
export class SucursalController {
constructor(private readonly sucursalService: SucursalService) { }
@Post()
async create(@Body() sucursalDto: SucursalDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeSucursal.create.title,
routeSucursal.create.route,
);
try {
const result = await this.sucursalService.create(sucursalDto);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
@Get(':idSucursal')
async findOne(@Param('idSucursal') idSucursal: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeSucursal.one.title,
routeSucursal.one.route,
);
try {
const result = await this.sucursalService.findOne(idSucursal);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
@Post('find')
async find(@Body() findByDto: FindDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeSucursal.find.title,
routeSucursal.find.route,
);
try {
const {
orderByDirection,
orderByAttribute,
whereConditions,
limit,
offset,
} = findByDto;
const result = await this.sucursalService.find(
whereConditions,
orderByAttribute,
orderByDirection,
limit,
offset,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = result.data;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Patch(':idSucursal')
async update(
@Param('idSucursal') idSucursal: string,
@Body() updateSucursalDto: UpdateSucursalDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeSucursal.update.title,
routeSucursal.update.route,
);
try {
const result = await this.sucursalService.update(
idSucursal,
updateSucursalDto,
);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
apiResult.data = [result.object];
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
@Delete(':idSucursal')
async remove(@Param('idSucursal') idSucursal: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeSucursal.remove.title,
routeSucursal.remove.route,
);
try {
const result = await this.sucursalService.remove(idSucursal);
if (result.boolean) {
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = result.message;
apiResult.boolean = true;
apiResult.rows = result.number;
} else {
apiResult.code = HttpStatus.BAD_REQUEST;
apiResult.message = result.message;
}
} catch (error: any) {
apiResult.code = error.status;
apiResult.message = error;
}
return apiResult;
}
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SucursalController } from './sucursal.controller.js';
import { SucursalService } from './sucursal.service.js';
import { Sucursal } from './entities/sucursal.entity.js';
import { Empresa } from '../empresa/entities/empresa.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Sucursal, Empresa])],
controllers: [SucursalController],
providers: [SucursalService],
exports: [SucursalService],
})
export class SucursalModule {}

View file

@ -0,0 +1,26 @@
export const routeSucursal = {
multiple: {
route: '[POST].../v1/sucursal/multiple',
title: 'Create Multiple Sucursal',
},
create: {
route: '[POST].../v1/sucursal',
title: 'Create Sucursal',
},
one: {
route: '[GET].../v1/sucursal/:idSucursal',
title: 'Find One Sucursal',
},
find: {
route: '[POST].../v1/sucursal/find',
title: 'Find Sucursal WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/sucursal/:idSucursal',
title: 'Update Sucursal',
},
remove: {
route: '[DELETE].../v1/sucursal/:idSucursal',
title: 'Remove Sucursal',
},
};

View file

@ -0,0 +1,150 @@
import { Injectable } from '@nestjs/common';
import { SucursalDto } from './dto/sucursal.dto.js';
import { UpdateSucursalDto } from './dto/update-sucursal.dto.js';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { defaultServiceResult, ServiceResult } from '../../shared/interfaces/service.result.js';
import { WhereCondition } from '../../shared/type/query-builder.types.js';
import { Sucursal } from './entities/sucursal.entity.js';
import { Empresa } from '../empresa/entities/empresa.entity.js';
@Injectable()
export class SucursalService {
constructor(
@InjectRepository(Sucursal)
private sucursalRepo: Repository<Sucursal>,
@InjectRepository(Empresa)
private empresaRepo: Repository<Empresa>,
) { }
async create(sucursalDto: SucursalDto): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
// Heredar valores de Empresa
const empresa = await this.empresaRepo.findOne({ where: {} });
const sucursalData = {
...sucursalDto,
diasIncluidos: empresa?.diasIncluidos ?? 5,
costoBase: empresa?.costoBase ?? 5,
costoDiaAdicional: empresa?.costoDiaAdicional ?? 2,
diasParaAlmacen: empresa?.diasParaAlmacen ?? 15,
colorActual: sucursalDto.colorSucursal ?? '#4338CA',
};
const result = this.sucursalRepo.create(sucursalData);
await this.sucursalRepo.save(result);
serviceResult.boolean = true;
serviceResult.message = 'Sucursal se ha agregado correctamente.';
serviceResult.number = 1;
serviceResult.object = result;
return serviceResult;
}
async findOne(idSucursal: string): Promise<ServiceResult> {
let serviceResult = {
boolean: false,
message: '',
number: 0,
object: null,
data: null,
} as ServiceResult;
const result = await this.sucursalRepo.findOneBy({ idSucursal });
if (result) {
serviceResult.boolean = true;
serviceResult.message = 'Existe una Sucursal.';
serviceResult.number = 1;
serviceResult.object = result;
} else {
serviceResult.message = 'No existe Sucursal.';
}
return serviceResult;
}
async find(
whereConditions: WhereCondition[],
orderByAttribute: string,
orderByDirection: 'ASC' | 'DESC',
limit: number,
offset: number,
): Promise<ServiceResult> {
const serviceResult = defaultServiceResult();
try {
let query = this.sucursalRepo.createQueryBuilder('sucursal');
whereConditions.forEach((condition, index) => {
const { attribute, value, operator = '=' } = condition;
const paramKey = `param${index}`;
const param = {
[paramKey]: operator === 'like' ? `%${value}%` : value,
};
const whereMethod = index === 0 ? 'where' : 'andWhere';
query = query[whereMethod](
`sucursal.${attribute} ${operator} :${paramKey}`,
param,
);
});
query = query.orderBy(`sucursal.${orderByAttribute}`, orderByDirection);
const totalRegistros = await query.getCount();
const skip = (offset - 1) * limit;
query = query.skip(skip).take(limit);
const result = await query.getMany();
const count = result.length;
serviceResult.boolean = count > 0;
serviceResult.message = `${count} Sucursal(es) encontrado(s).`;
serviceResult.number = totalRegistros;
serviceResult.data = result;
} catch (error: any) {
serviceResult.boolean = false;
serviceResult.message = `Error en el servicio: ${error.message}`;
}
return serviceResult;
}
async update(
idSucursal: string,
updateSucursalDto: UpdateSucursalDto,
): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.sucursalRepo.update(
idSucursal,
updateSucursalDto,
);
serviceResult.boolean = result.affected === 1 ? true : false;
serviceResult.message = 'Se ha actualizado correctamente.';
serviceResult.number = result.affected || 0;
serviceResult.object = result;
return serviceResult;
}
async remove(idSucursal: string): Promise<ServiceResult> {
let serviceResult = defaultServiceResult();
const result = await this.sucursalRepo.delete(idSucursal);
serviceResult.boolean = result.affected === 1 ? true : false;
serviceResult.message =
result.affected === 1
? 'Se ha eliminado correctamente.'
: 'No se ha encontrado la Sucursal.';
serviceResult.number = result.affected || 0;
return serviceResult;
}
}

View file

@ -0,0 +1,14 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GenerarQrDto {
@ApiProperty({ description: 'UUID v4 del token de retiro' })
@IsString()
@IsNotEmpty()
tokenRetiro: string;
@ApiProperty({ description: 'Código completo del paquete' })
@IsString()
@IsNotEmpty()
codigoPaquete: string;
}

View file

@ -0,0 +1,16 @@
import { IsOptional, IsIn } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export const OCR_PROVIDERS = ['tesseract', 'ocrspace', 'google-vision', 'mimo'] as const;
export type OcrProviderName = (typeof OCR_PROVIDERS)[number];
export class ProcesarOcrDto {
@ApiPropertyOptional({
description: 'Proveedor de OCR a utilizar',
enum: OCR_PROVIDERS,
default: 'tesseract',
})
@IsOptional()
@IsIn(OCR_PROVIDERS)
provider?: OcrProviderName;
}

View file

@ -0,0 +1,123 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseInterceptors,
UploadedFile,
Res,
NotFoundException,
HttpStatus,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import * as fs from 'fs';
import * as path from 'path';
import { UploadService } from './upload.service.js';
import { GenerarQrDto } from './dto/generar-qr.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeUpload } from './upload.router.js';
@Controller('upload')
export class UploadController {
constructor(private readonly service: UploadService) {}
@Post('paquete')
@UseInterceptors(FileInterceptor('file'))
async subirPaquete(@UploadedFile() file: Express.Multer.File): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUpload.subirPaquete.title,
routeUpload.subirPaquete.route,
);
try {
const result = await this.service.subirPaquete(file);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Imagen subida correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error al procesar';
}
return apiResult;
}
@Post('qr')
async generarQr(@Body() dto: GenerarQrDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUpload.generarQr.title,
routeUpload.generarQr.route,
);
try {
const result = await this.service.generarQr(dto.tokenRetiro, dto.codigoPaquete);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'QR generado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error al procesar';
}
return apiResult;
}
@Post('ocr')
@UseInterceptors(FileInterceptor('file'))
async procesarOcr(
@UploadedFile() file: Express.Multer.File,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUpload.procesarOcr.title,
routeUpload.procesarOcr.route,
);
try {
const result = await this.service.procesarOcr(file);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OCR procesado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error al procesar';
}
return apiResult;
}
@Get(':filename')
async servirArchivo(@Param('filename') filename: string, @Res() res: any): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUpload.servirArchivo.title,
routeUpload.servirArchivo.route,
);
try {
const filePath = path.join(process.cwd(), 'uploads', filename);
if (!fs.existsSync(filePath)) {
throw new NotFoundException('Archivo no encontrado');
}
return res.sendFile(filePath);
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message || 'Error al procesar';
}
return apiResult;
}
}

View file

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import * as multer from 'multer';
import { UploadController } from './upload.controller.js';
import { UploadService } from './upload.service.js';
import { OcrModule } from '../../shared/services/ocr/ocr.module.js';
@Module({
imports: [
MulterModule.register({ storage: multer.memoryStorage() }),
OcrModule,
],
controllers: [UploadController],
providers: [UploadService],
exports: [UploadService],
})
export class UploadModule {}

View file

@ -0,0 +1,18 @@
export const routeUpload = {
subirPaquete: {
route: '[POST].../v1/upload/paquete',
title: 'Subir Imagen Paquete',
},
generarQr: {
route: '[POST].../v1/upload/qr',
title: 'Generar QR Paquete',
},
procesarOcr: {
route: '[POST].../v1/upload/ocr',
title: 'Procesar OCR Imagen',
},
servirArchivo: {
route: '[GET].../v1/upload/:filename',
title: 'Servir Archivo',
},
};

View file

@ -0,0 +1,121 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import * as QRCode from 'qrcode';
import { OcrFactory } from '../../shared/services/ocr/ocr.factory.js';
@Injectable()
export class UploadService {
private uploadsPath = path.join(process.cwd(), 'uploads');
constructor(
private readonly ocrFactory: OcrFactory,
private readonly configService: ConfigService,
) {}
async subirPaquete(file: Express.Multer.File): Promise<{ url: string }> {
this.validarArchivo(file);
const compressBuffer = await this.comprimirSiNecesario(file.buffer);
const filename = `${uuidv4()}.jpg`;
const filePath = path.join(this.uploadsPath, 'paquetes', filename);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, compressBuffer);
return { url: `/uploads/paquetes/${filename}` };
}
async generarQr(tokenRetiro: string, codigoPaquete: string): Promise<{ url: string }> {
const filename = `${tokenRetiro}.png`;
const filePath = path.join(this.uploadsPath, 'qr', filename);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const qrBuffer = await QRCode.toBuffer(tokenRetiro, {
type: 'png',
width: 400,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
});
fs.writeFileSync(filePath, qrBuffer);
return { url: `/uploads/qr/${filename}` };
}
async procesarOcr(
file: Express.Multer.File,
): Promise<{
fotoUrl: string;
cliente: { nombreCliente: string; whatsappCliente: string };
}> {
this.validarArchivo(file);
const provider = this.ocrFactory.getProvider();
// Comprimir solo para mimo (reduce size para API)
let ocrBuffer: Buffer;
const providerName = this.configService.get<string>('OCR_PROVIDER', 'tesseract');
if (providerName === 'mimo') {
ocrBuffer = await this.comprimirSiNecesario(file.buffer);
} else {
ocrBuffer = file.buffer;
}
const filename = `${uuidv4()}.jpg`;
const filePath = path.join(this.uploadsPath, 'paquetes', filename);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, ocrBuffer);
let nombreCliente = '';
let whatsappCliente = '';
try {
const ocrResult = await provider.reconocer(ocrBuffer);
console.log('DEBUG OCR result from provider:', JSON.stringify(ocrResult));
nombreCliente = ocrResult.nombre || '';
whatsappCliente = ocrResult.whatsapp || '';
} catch (error: any) {
console.log('DEBUG OCR failed, continuing without client data:', error.message);
}
return {
fotoUrl: `/uploads/paquetes/${filename}`,
cliente: { nombreCliente, whatsappCliente },
};
}
private validarArchivo(file: Express.Multer.File): void {
if (!file) {
throw new BadRequestException('No se proporcionó archivo');
}
const maxSize = 400 * 1024;
if (file.size > maxSize) {
throw new BadRequestException('El archivo supera los 400 KB');
}
const allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedMimes.includes(file.mimetype)) {
throw new BadRequestException('Formato de archivo no soportado. Use JPEG, PNG o WebP');
}
}
private async comprimirSiNecesario(buffer: Buffer): Promise<Buffer> {
const maxSize = 400 * 1024;
if (buffer.length <= maxSize) {
return buffer;
}
return sharp(buffer)
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
}
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { UsuarioDto } from './usuario.dto.js';
export class UpdateUsuarioDto extends PartialType(UsuarioDto) {}

View file

@ -0,0 +1,38 @@
import { IsString, IsNotEmpty, IsEnum, MinLength, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UsuarioDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idUsuario?: string;
@ApiProperty({ example: 'Juan Pérez' })
@IsString()
@IsNotEmpty()
nombre: string;
@ApiProperty({ example: 'juan' })
@IsString()
@IsNotEmpty()
usuario: string;
@ApiProperty({ example: 'password123' })
@IsString()
@MinLength(8)
password: string;
@ApiProperty({ example: '¿Cuál es tu color favorito?' })
@IsString()
@IsNotEmpty()
pregunta: string;
@ApiProperty({ example: 'azul' })
@IsString()
@IsNotEmpty()
respuesta: string;
@ApiProperty({ enum: ['ADMIN', 'ENCARGADO', 'RECEPCION', 'ENTREGA'] })
@IsEnum(['ADMIN', 'ENCARGADO', 'RECEPCION', 'ENTREGA'])
rol: string;
}

View file

@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class Usuario {
@PrimaryGeneratedColumn('uuid')
idUsuario: string;
@Column()
nombre: string;
@Column()
usuario: string;
@Column()
password: string;
@Column()
pregunta: string;
@Column()
respuesta: string;
@Column({ type: 'enum', enum: ['ADMIN', 'ENCARGADO', 'RECEPCION', 'ENTREGA'] })
rol: string;
}

View file

@ -0,0 +1,136 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, HttpStatus } from '@nestjs/common';
import { UsuarioService } from './usuario.service.js';
import { UsuarioDto } from './dto/usuario.dto.js';
import { UpdateUsuarioDto } from './dto/update-usuario.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeUsuario } from './usuario.router.js';
import { Public } from '../../shared/interfaces/auth.interface.js';
import { RequireApiKey } from '../../shared/interfaces/api-key.interface.js';
@Controller('usuario')
export class UsuarioController {
constructor(private readonly service: UsuarioService) {}
@Get()
async findAll(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUsuario.find.title,
routeUsuario.find.route,
);
try {
const data = await this.service.findAll();
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = data.length;
apiResult.data = data;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get(':idUsuario')
async findOne(@Param('idUsuario') idUsuario: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUsuario.one.title,
routeUsuario.one.route,
);
try {
const data = await this.service.findOne(idUsuario);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'OK';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post()
@Public()
@RequireApiKey()
async create(@Body() dto: UsuarioDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUsuario.create.title,
routeUsuario.create.route,
);
try {
const data = await this.service.create(dto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.CREATED;
apiResult.message = 'Usuario registrado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Patch(':idUsuario')
async update(
@Param('idUsuario') idUsuario: string,
@Body() dto: UpdateUsuarioDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUsuario.update.title,
routeUsuario.update.route,
);
try {
const data = await this.service.update(idUsuario, dto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Usuario actualizado correctamente';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [data];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Delete(':idUsuario')
async remove(@Param('idUsuario') idUsuario: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeUsuario.remove.title,
routeUsuario.remove.route,
);
try {
await this.service.remove(idUsuario);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Usuario eliminado correctamente';
apiResult.boolean = true;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UsuarioController } from './usuario.controller.js';
import { UsuarioService } from './usuario.service.js';
import { Usuario } from './entities/usuario.entity.js';
@Module({
imports: [
TypeOrmModule.forFeature([Usuario]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRATION', '7d') },
}),
}),
],
controllers: [UsuarioController],
providers: [UsuarioService],
exports: [UsuarioService],
})
export class UsuarioModule {}

View file

@ -0,0 +1,26 @@
export const routeUsuario = {
multiple: {
route: '[POST].../v1/usuario/multiple',
title: 'Create Multiple Usuario',
},
create: {
route: '[POST].../v1/usuario',
title: 'Create Usuario',
},
one: {
route: '[GET].../v1/usuario/:idUsuario',
title: 'Find One Usuario',
},
find: {
route: '[POST].../v1/usuario/find',
title: 'Find Usuario WhereCondition[], Attribute & Direccion',
},
update: {
route: '[PATCH].../v1/usuario/:idUsuario',
title: 'Update Usuario',
},
remove: {
route: '[DELETE].../v1/usuario/:idUsuario',
title: 'Remove Usuario',
},
};

View file

@ -0,0 +1,93 @@
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Usuario } from './entities/usuario.entity.js';
import { UsuarioDto } from './dto/usuario.dto.js';
import { UpdateUsuarioDto } from './dto/update-usuario.dto.js';
@Injectable()
export class UsuarioService {
constructor(
@InjectRepository(Usuario)
private repo: Repository<Usuario>,
private jwtService: JwtService,
) {}
async findAll() {
return this.repo.find({
select: {
idUsuario: true,
nombre: true,
usuario: true,
rol: true,
},
});
}
async findOne(id: string) {
const usuario = await this.repo.findOne({
where: { idUsuario: id },
select: {
idUsuario: true,
nombre: true,
usuario: true,
rol: true,
},
});
if (!usuario) throw new NotFoundException('Usuario no encontrado');
return usuario;
}
async create(data: UsuarioDto) {
const exists = await this.repo.findOne({ where: { usuario: data.usuario } });
if (exists) throw new ConflictException('El nombre de usuario ya existe');
const hashedPassword = await bcrypt.hash(data.password!, 10);
const hashedRespuesta = await bcrypt.hash(data.respuesta!, 10);
const usuario = this.repo.create({
...data,
password: hashedPassword,
respuesta: hashedRespuesta,
} as any);
const saved = await this.repo.save(usuario);
const { password, respuesta, ...result } = saved as any;
const payload = {
sub: result.idUsuario,
usuario: result.usuario,
rol: result.rol,
};
return {
token: this.jwtService.sign(payload),
usuario: result,
};
}
async update(id: string, data: UpdateUsuarioDto) {
const usuario = await this.repo.findOne({ where: { idUsuario: id } });
if (!usuario) throw new NotFoundException('Usuario no encontrado');
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
if (data.respuesta) {
data.respuesta = await bcrypt.hash(data.respuesta, 10);
}
Object.assign(usuario, data);
const saved = await this.repo.save(usuario);
const { password, respuesta, ...result } = saved;
return result;
}
async remove(id: string) {
const usuario = await this.repo.findOne({ where: { idUsuario: id } });
if (!usuario) throw new NotFoundException('Usuario no encontrado');
await this.repo.remove(usuario);
}
}

View file

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { ZonaAlmacenamientoDto } from './zona.dto.js';
export class UpdateZonaAlmacenamientoDto extends PartialType(ZonaAlmacenamientoDto) {}

View file

@ -0,0 +1,28 @@
import { IsString, IsOptional, IsNotEmpty, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ZonaAlmacenamientoDto {
@ApiProperty({ example: 'uuid-id' })
@IsUUID()
@IsOptional()
idZonaAlmacenamiento: string;
@ApiProperty({ example: 'uuid-sucursal' })
@IsString()
idSucursal: string;
@ApiPropertyOptional({ example: 'AM' })
@IsString()
@IsOptional()
prefijo?: string;
@ApiProperty({ example: 'Almacén' })
@IsString()
@IsNotEmpty()
nombre: string;
@ApiPropertyOptional({ example: '#FF5722' })
@IsString()
@IsOptional()
color?: string;
}

View file

@ -0,0 +1,19 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class ZonaAlmacenamiento {
@PrimaryGeneratedColumn('uuid')
idZonaAlmacenamiento: string;
@Column()
idSucursal: string;
@Column({ nullable: true })
prefijo: string;
@Column()
nombre: string;
@Column({ nullable: true })
color: string;
}

View file

@ -0,0 +1,132 @@
import { Controller, Get, Post, Put, Delete, Body, Param, HttpStatus } from '@nestjs/common';
import { ZonaService } from './zona.service.js';
import { ZonaAlmacenamientoDto } from './dto/zona.dto.js';
import { UpdateZonaAlmacenamientoDto } from './dto/update-zona.dto.js';
import { ApiResult, defaultApiResult } from '../../shared/interfaces/api.result.js';
import { routeZona } from './zona.router.js';
@Controller('zona')
export class ZonaController {
constructor(private readonly zonaService: ZonaService) {}
@Get()
async findAll(): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeZona.find.title,
routeZona.find.route,
);
try {
const result = await this.zonaService.findAll();
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = `${result.length} Zona(s) encontrado(s).`;
apiResult.boolean = true;
apiResult.rows = result.length;
apiResult.data = result;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Get(':idZonaAlmacenamiento')
async findOne(@Param('idZonaAlmacenamiento') idZonaAlmacenamiento: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeZona.one.title,
routeZona.one.route,
);
try {
const result = await this.zonaService.findOne(idZonaAlmacenamiento);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Existe un Zona.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Post()
async create(@Body() zonaAlmacenamientoDto: ZonaAlmacenamientoDto): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeZona.create.title,
routeZona.create.route,
);
try {
const result = await this.zonaService.create(zonaAlmacenamientoDto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Zona se ha agregado correctamente.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Put(':idZonaAlmacenamiento')
async update(
@Param('idZonaAlmacenamiento') idZonaAlmacenamiento: string,
@Body() updateZonaAlmacenamientoDto: UpdateZonaAlmacenamientoDto,
): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeZona.update.title,
routeZona.update.route,
);
try {
const result = await this.zonaService.update(idZonaAlmacenamiento, updateZonaAlmacenamientoDto);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Se ha actualizado correctamente.';
apiResult.boolean = true;
apiResult.rows = 1;
apiResult.data = [result];
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
@Delete(':idZonaAlmacenamiento')
async remove(@Param('idZonaAlmacenamiento') idZonaAlmacenamiento: string): Promise<ApiResult> {
let apiResult = defaultApiResult(
routeZona.remove.title,
routeZona.remove.route,
);
try {
await this.zonaService.remove(idZonaAlmacenamiento);
apiResult.status = 'correct';
apiResult.code = HttpStatus.OK;
apiResult.message = 'Se ha eliminado correctamente.';
apiResult.boolean = true;
} catch (error: any) {
apiResult.code = error.status || HttpStatus.INTERNAL_SERVER_ERROR;
apiResult.message = error.message;
}
return apiResult;
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ZonaController } from './zona.controller.js';
import { ZonaService } from './zona.service.js';
import { ZonaAlmacenamiento } from './entities/zona.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([ZonaAlmacenamiento])],
controllers: [ZonaController],
providers: [ZonaService],
exports: [ZonaService],
})
export class ZonaModule {}

Some files were not shown because too many files have changed in this diff Show more